Compare commits

...

17 Commits

Author SHA1 Message Date
Claude Bot
4dc32bdb2c fix(net): flush pending write callback on socket close to prevent stalled streams
When a native socket closes while there's a pending write callback
(kwriteCallback), the writable stream gets stuck waiting for that callback
to complete. This prevents the stream from proceeding through its shutdown
sequence, so `finish` and `close` events are never emitted.

This was most noticeable when multiple server-side sockets had their remote
clients disconnect during active writes — only one socket would complete the
full event lifecycle while the remaining sockets would stall after `end`.

The fix invokes the pending write callback with an error in both
`SocketHandlers.close` and `ServerHandlers.close`, matching what the
`error` handlers already do.

Closes #24808

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:59:41 +00:00
Jarred Sumner
9fbe6a5826 Update standalone-html.mdx 2026-02-17 23:30:04 -08:00
Jarred Sumner
c0d97ebd88 Add docs for standalone HTML 2026-02-17 23:22:31 -08:00
robobun
0b580054a7 fix(stripANSI): validate SIMD candidates to prevent infinite loop on non-escape control chars (#27015)
## Summary
- Fix infinite loop in `Bun.stripANSI()` when input contains control
characters in the `0x10-0x1F` range that are not ANSI escape introducers
(e.g. `0x16` SYN, `0x19` EM)
- The SIMD fast-path in `findEscapeCharacter` matched the broad range
`0x10-0x1F` / `0x90-0x9F`, but `consumeANSI` only handles a subset of
those characters. When `consumeANSI` returned the same pointer for an
unrecognized byte, the main loop in `stripANSI` never advanced, causing
a hang in release builds and an assertion failure in debug builds.
- Fix verifies SIMD candidates through `isEscapeCharacter()` before
returning, matching the behavior the scalar fallback path already had

## Test plan
- [x] Added regression test in `test/regression/issue/27014.test.ts`
with 4 test cases
- [x] Verified test hangs with system bun (v1.3.9) confirming the bug
- [x] All 4 new tests pass with debug build
- [x] All 265 existing `stripANSI.test.ts` tests pass with debug build

Closes #27014

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 15:28:27 -08:00
robobun
b817abe55e feat(bundler): add --compile --target=browser for self-contained HTML output (#27056)
## Summary
- Adds self-contained HTML output mode: `--compile --target=browser`
(CLI) or `compile: true, target: "browser"` (`Bun.build()` API)
- Produces HTML files with all JS, CSS, and assets inlined directly:
`<script src="...">` → inline `<script>`, `<link rel="stylesheet">` →
inline `<style>`, asset references → `data:` URIs
- All entrypoints must be `.html` files when using `--compile
--target=browser`
- Validates: errors if any entrypoints aren't HTML, or if `--splitting`
is used
- Useful for distributing `.html` files that work via `file://` URLs
without needing a web server or worrying about CORS restrictions

## Test plan
- [x] Added `test/bundler/standalone.test.ts` covering:
  - Basic JS inlining into HTML
  - CSS inlining into HTML  
  - Combined JS + CSS inlining
  - Asset inlining as data URIs
  - CSS `url()` references inlined as data URIs
  - Validation: non-HTML entrypoints rejected
  - Validation: mixed HTML/non-HTML entrypoints rejected
  - Validation: splitting rejected
  - `Bun.build()` API with `compile: true, target: "browser"`
  - CLI `--compile --target=browser`
  - Minification works with compile+browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-17 15:27:36 -08:00
Jarred Sumner
9256b3d777 fix(Error): captureStackTrace with non-stack function returns proper stack string (#27017)
### What does this PR do?

When Error.captureStackTrace(e, fn) is called with a function that isn't
in the call stack, all frames are filtered out and e.stack should return
just the error name and message (e.g. "Error: test"), matching Node.js
behavior. Previously Bun returned undefined because:

1. The empty frame vector replaced the original stack frames via
setStackFrames(), but the lazy stack accessor was only installed when
hasMaterializedErrorInfo() was true (i.e. stack was previously
accessed). When it wasn't, JSC's internal materialization saw the
empty/null frames and produced no stack property at all.

2. The custom lazy getter returned undefined when stackTrace was
nullptr, instead of computing the error name+message string with zero
frames.

Fix: always force materialization before replacing frames, always
install the custom lazy accessor, and handle nullptr stackTrace in the
getter by computing the error string with an empty frame list.


### How did you verify your code works?

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-17 12:34:19 -08:00
SUZUKI Sosuke
6763fe5a8a fix: remove unsafe ObjectInitializationScope from fromEntries (#27088)
## Summary

Speculative fix for
[BUN-1K54](https://bun-p9.sentry.io/issues/7260165386/) — a segfault in
`JSC__JSValue__fromEntries` with 238 occurrences on Windows x86_64 (Bun
1.3.9).

## Problem

`JSC__JSValue__fromEntries` wraps its property insertion loop inside a
`JSC::ObjectInitializationScope`. This scope is designed for fast,
allocation-free object initialization using `putDirectOffset`. However,
the code uses `putDirect` (which can trigger structure transitions)
along with `toJSStringGC` and `toIdentifier` (which allocate on the GC
heap).

In **debug builds**, `ObjectInitializationScope` includes `AssertNoGC`
and `DisallowVMEntry` guards that would catch this misuse immediately.
In **release builds**, the scope is essentially a no-op (only emits a
`mutatorFence` on destruction), so GC can silently trigger during the
loop. When this happens, the GC may encounter partially-initialized
object slots containing garbage values, leading to a segfault.

## Fix

- Remove the `ObjectInitializationScope` block, since `putDirect` with
allocating helpers is incompatible with its contract.
- Add `RETURN_IF_EXCEPTION` checks inside each loop iteration to
properly propagate exceptions (e.g., OOM during string allocation).

## Test

Added a regression test that creates a `FileSystemRouter` with 128
routes and accesses `router.routes` under GC pressure. Verified via
temporary `fprintf` logging that the test exercises the modified
`JSC__JSValue__fromEntries` code path with `initialCapacity=128,
clone=true`.

Note: The original crash is a GC timing issue that cannot be
deterministically reproduced in a test. This test validates correctness
of the code path rather than reproducing the specific crash.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:30:09 -08:00
Alan Stott
7848648e09 fix: clean up raw TCP socket on TLS upgrade close (#26766)
## Summary

Fixes #12117, #24118, #25948

When a TCP socket is upgraded to TLS via `tls.connect({ socket })`,
`upgradeTLS()` creates **two** `TLSSocket` structs — a TLS wrapper and a
raw TCP wrapper. Both are `markActive()`'d and `ref()`'d. On close, uws
fires `onClose` through the **TLS context only**, so the TLS wrapper is
properly cleaned up, but the raw TCP wrapper's `onClose` never fires.
Its `has_pending_activity` stays `true` forever and its `ref_count` is
never decremented, **leaking one raw `TLSSocket` per upgrade cycle**.

This affects any code using the `tls.connect({ socket })` "starttls"
pattern:
- **MongoDB Node.js driver** — SDAM heartbeat connections cycle TLS
upgrades every ~10s, causing unbounded memory growth in production
- **mysql2** TLS upgrade path
- Any custom starttls implementation

### The fix

Adds a `defer` block in `NewWrappedHandler(true).onClose` that cleans up
the raw TCP socket when the TLS socket closes:

```zig
defer {
    if (!this.tcp.socket.isDetached()) {
        this.tcp.socket.detach();
        this.tcp.has_pending_activity.store(false, .release);
        this.tcp.deref();
    }
}
```

- **`isDetached()` guard** — skips cleanup if the raw socket was already
closed through another code path (e.g., JS-side `handle.close()`)
- **`socket.detach()`** — marks `InternalSocket` as `.detached` so
`isClosed()` returns `true` safely (the underlying `us_socket_t` is
freed when uws closes the TLS context)
- **`has_pending_activity.store(false)`** — allows JSC GC to collect the
raw socket's JS wrapper
- **`deref()`** — balances the `ref()` from `upgradeTLS`; the remaining
ref is the implicit one from JSC (`ref_count.init() == 1`). When GC
later calls `finalize()` → `deref()`, ref_count hits 0 and `deinit()`
runs the full cleanup chain (markInactive, handlers, poll_ref,
socket_context)

`markInactive()` is intentionally **not** called in the defer — it must
run inside `deinit()` to avoid double-freeing the handlers struct.

### Why Node.js doesn't have this bug

Node.js implements TLS upgrades purely in JavaScript/C++ with OpenSSL,
where the TLS wrapper takes ownership of the underlying socket. There is
no separate "raw socket wrapper" that needs independent cleanup.

## Test Results

### Regression test
```
$ bun test test/js/node/tls/node-tls-upgrade-leak.test.ts
 1 pass, 0 fail
```
Creates 20 TCP→TLS upgrade cycles, closes all connections, runs GC,
asserts `TLSSocket` count stays below 10.

### Existing TLS test suite (all passing)
```
node-tls-upgrade.test.ts      1 pass
node-tls-connect.test.ts     24 pass, 1 skip
node-tls-server.test.ts      21 pass
node-tls-cert.test.ts        25 pass, 3 todo
renegotiation.test.ts          6 pass
```

### MongoDB TLS scenario (patched Bun, 4 minutes)
```
Baseline: RSS=282.4 MB | Heap Used=26.4 MB
Check #4:  RSS=166.7 MB | Heap Used=24.2 MB — No TLSSocket growth. RSS DECREASED.
```

## Test plan

- [x] New regression test passes (`node-tls-upgrade-leak.test.ts`)
- [x] All existing TLS tests pass (upgrade, connect, server, cert,
renegotiation)
- [x] MongoDB TLS scenario shows zero `TLSSocket` accumulation
- [x] Node.js control confirms leak is Bun-specific
- [ ] CI passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-16 23:42:57 -08:00
Jarred Sumner
379daff22d Fix test failure (#27073)
### What does this PR do?

### How did you verify your code works?

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 22:52:43 -08:00
robobun
5b0db0191e fix(bundler): copy non-JS/CSS files referenced as URL assets in HTML (#27039)
## Summary

- Fix `<link rel="manifest" href="./manifest.json" />` (and similar
non-JS/CSS URL assets) resulting in 404s when using `Bun.build` with
HTML entrypoints
- The HTML scanner correctly identifies these as `ImportKind.url`
imports, but the bundler was assigning the extension-based loader (e.g.
`.json`) which parses the file instead of copying it as a static asset
- Force the `.file` loader for `ImportKind.url` imports when the
resolved loader wouldn't `shouldCopyForBundling()` and isn't JS/CSS/HTML
(which have their own handling)

## Test plan

- [x] Added `html/manifest-json` test: verifies manifest.json is copied
as hashed asset and HTML href is rewritten
- [x] Added `html/xml-asset` test: verifies `.webmanifest` files are
also handled correctly
- [x] All 20 HTML bundler tests pass (`bun bd test
test/bundler/bundler_html.test.ts`)
- [x] New tests fail on system bun (`USE_SYSTEM_BUN=1`) confirming they
validate the fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 18:06:25 -08:00
robobun
9ef9ac1db1 fix(http): fix setHeaders throwing ERR_HTTP_HEADERS_SENT on new requests (#27050)
## Summary

- Fix `OutgoingMessage.setHeaders()` incorrectly throwing
`ERR_HTTP_HEADERS_SENT` on brand new `ClientRequest` instances
- The guard condition `this[headerStateSymbol] !==
NodeHTTPHeaderState.none` failed when `headerStateSymbol` was
`undefined` (since `ClientRequest` doesn't call the `OutgoingMessage`
constructor), and was also stricter than Node.js which only checks
`this._header`
- Align the check with the working `setHeader()` (singular) method: only
throw when `_header` is set or `headerStateSymbol` equals `sent`

Closes #27049

## Test plan
- [x] New regression test `test/regression/issue/27049.test.ts` covers:
  - `ClientRequest.setHeaders()` with `Headers` object
  - `ClientRequest.setHeaders()` with `Map`
  - `ServerResponse.setHeaders()` before headers are sent
- [x] Test fails with system bun (`USE_SYSTEM_BUN=1`)
- [x] Test passes with debug build (`bun bd test`)
- [x] Existing header-related tests in `node-http.test.ts` still pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-16 18:04:35 -08:00
robobun
f5d98191b7 fix(install): store and verify SHA-512 integrity hash for GitHub tarball dependencies (#27019)
## Summary

- Compute SHA-512 hash of GitHub tarball bytes during extraction and
store in `bun.lock`
- Verify the hash on subsequent installs when re-downloading, rejecting
tampered tarballs
- Automatically upgrade old lockfiles without integrity by computing and
persisting the hash
- Maintain backward compatibility with old lockfile format (no integrity
field)

Fixes GHSA-pfwx-36v6-832x

## Lockfile format change

```
Before: ["pkg@github:user/repo#ref", {}, "resolved-commit"]
After:  ["pkg@github:user/repo#ref", {}, "resolved-commit", "sha512-..."]
```

The integrity field is optional for backward compatibility. Old
lockfiles are automatically upgraded when the tarball is re-downloaded.

## Test plan

- [x] Fresh install stores SHA-512 integrity hash in lockfile
- [x] Re-install with matching hash succeeds
- [x] Re-install with mismatched hash rejects the tarball
- [x] Old lockfile without integrity is auto-upgraded with hash on
re-download
- [x] Cache hits still work without re-downloading
- [x] Existing GitHub dependency tests pass (10/10)
- [x] Existing git resolution snapshot test passes
- [x] Yarn migration snapshot tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-16 17:53:32 -08:00
robobun
83bca9bea8 docs: fix plugin API documentation to reflect onStart/onEnd support (#27068)
## Summary
- Fixes the esbuild migration guide (`docs/bundler/esbuild.mdx`) which
incorrectly stated that `onStart`, `onEnd`, `onDispose`, and `resolve`
were all unimplemented. `onStart` and `onEnd` **are** implemented — only
`onDispose` and `resolve` remain unimplemented.
- Adds missing `onEnd()` documentation section to both
`docs/bundler/plugins.mdx` and `docs/runtime/plugins.mdx`, including
type signature, description, and usage examples.
- Adds `onEnd` to the type reference overview and lifecycle hooks list
in both plugin docs.

Fixes #27083

## Test plan
- Documentation-only change — no code changes.
- Verified the `onEnd` implementation exists in
`src/js/builtins/BundlerPlugin.ts` and matches the documented API.
- Verified `onStart` implementation exists and is fully functional.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 17:44:09 -08:00
robobun
7794cc866e fix(http): preserve explicit Content-Length header with streaming request body (#27062)
## Summary

- When `http.ClientRequest.write()` was called more than once (streaming
data in chunks), Bun was stripping the explicitly-set `Content-Length`
header and switching to `Transfer-Encoding: chunked`. Node.js preserves
`Content-Length` in all cases when it's explicitly set by the user.
- This caused real-world failures (e.g. Vercel CLI file uploads) where
large binary files streamed via multiple `write()` calls had their
Content-Length stripped, causing server-side "invalid file size" errors.
- The fix preserves the user's explicit `Content-Length` for streaming
request bodies and skips chunked transfer encoding framing when
`Content-Length` is set.

Closes #27061
Closes #26976

## Changes

- **`src/http.zig`**: When a streaming request body has an explicit
`Content-Length` header set by the user, use that instead of adding
`Transfer-Encoding: chunked`. Added
`is_streaming_request_body_with_content_length` flag to track this.
- **`src/bun.js/webcore/fetch/FetchTasklet.zig`**: Skip chunked transfer
encoding framing (`writeRequestData`) and the chunked terminator
(`writeEndRequest`) when the request has an explicit `Content-Length`.
- **`test/regression/issue/27061.test.ts`**: Regression test covering
multiple write patterns (2x write, write+end(data), 3x write) plus
validation that chunked encoding is still used when no `Content-Length`
is set.

## Test plan

- [x] New regression test passes with `bun bd test
test/regression/issue/27061.test.ts`
- [x] Test fails with `USE_SYSTEM_BUN=1` (confirms the bug exists in
current release)
- [x] Existing `test/js/node/http/` tests pass (no regressions)
- [x] Fetch file upload tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 17:43:23 -08:00
robobun
70b354aa04 fix(bundler): include CSS in all HTML entrypoints sharing a deduplicated CSS chunk (#27040) 2026-02-15 03:36:06 -08:00
Jarred Sumner
9d5a800c3d fix(napi,timers): callback scope (#27026) 2026-02-15 03:33:48 -08:00
Jarred Sumner
77ca318336 Reduce the number of closures in generated bundler code (#27022)
### Problem

The bundler's `__toESM` helper creates a new getter-wrapped proxy object
every time a CJS
module is imported. In a large app, a popular dependency like React can
be imported 600+
times — each creating a fresh object with ~44 getter properties. This
produces ~27K
unnecessary `GetterSetter` objects, ~25K closures, and ~25K
`JSLexicalEnvironment` scope
objects at startup.

Additionally, `__export` and `__exportValue` use `var`-scoped loop
variables captured by
setter closures, meaning all setters incorrectly reference the last
iterated key (a latent
  bug).

### Changes

1. **`__toESM`: add WeakMap cache** — deduplicate repeated wrappings of
the same CJS
module. Two caches (one per `isNodeMode` value) to handle both import
modes correctly.
2. **Replace closures with `.bind()`** — `() => obj[key]` becomes
`__accessProp.bind(obj,
key)`. BoundFunction is cheaper than Function + JSLexicalEnvironment,
and frees the for-in
  `JSPropertyNameEnumerator` from the closure scope.
3. **Fix var-scoping bug in `__export`/`__exportValue`** — setter
closures captured a
shared `var name` and would all modify the last iterated key. `.bind()`
eagerly captures
the correct key per iteration.
4. **`__toCommonJS`: `.map()` → `for..of`** — eliminates throwaway array
allocation.
5. **`__reExport`: single `getOwnPropertyNames` call** — was calling it
twice when
`secondTarget` was provided.

### Impact (measured on a ~23MB single-bundle app with 600+ React
imports)

| Metric | Before | After | Delta |
|--------|--------|-------|-------|
| **Total objects** | 745,985 | 664,001 | **-81,984 (-11%)** |
| **Heap size** | 115 MB | 111 MB | **-4 MB** |
| GetterSetter | 34,625 | 13,428 | -21,197 (-61%) |
| Function | 221,302 | 197,024 | -24,278 (-11%) |
| JSLexicalEnvironment | 70,101 | 44,633 | -25,468 (-36%) |
| Structure | 40,254 | 39,762 | -492 |
2026-02-15 00:36:57 -08:00
55 changed files with 2916 additions and 253 deletions

View File

@@ -198,13 +198,16 @@ const myPlugin: BunPlugin = {
};
```
The builder object provides some methods for hooking into parts of the bundling process. Bun implements `onResolve` and `onLoad`; it does not yet implement the esbuild hooks `onStart`, `onEnd`, and `onDispose`, and `resolve` utilities. `initialOptions` is partially implemented, being read-only and only having a subset of esbuild's options; use `config` (same thing but with Bun's `BuildConfig` format) instead.
The builder object provides some methods for hooking into parts of the bundling process. Bun implements `onStart`, `onEnd`, `onResolve`, and `onLoad`. It does not yet implement the esbuild hooks `onDispose` and `resolve`. `initialOptions` is partially implemented, being read-only and only having a subset of esbuild's options; use `config` (same thing but with Bun's `BuildConfig` format) instead.
```ts title="myPlugin.ts" icon="/icons/typescript.svg"
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "my-plugin",
setup(builder) {
builder.onStart(() => {
/* called when the bundle starts */
});
builder.onResolve(
{
/* onResolve.options */
@@ -225,6 +228,9 @@ const myPlugin: BunPlugin = {
};
},
);
builder.onEnd(result => {
/* called when the bundle is complete */
});
},
};
```

View File

@@ -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.
---

View File

@@ -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`.

View File

@@ -15,6 +15,7 @@ Plugins can register callbacks to be run at various points in the lifecycle of a
- `onResolve()`: Run before a module is resolved
- `onLoad()`: Run before a module is loaded
- `onBeforeParse()`: Run zero-copy native addons in the parser thread before a file is parsed
- `onEnd()`: Run after the bundle is complete
## Reference
@@ -39,6 +40,7 @@ type PluginBuilder = {
exports?: Record<string, any>;
},
) => void;
onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
config: BuildConfig;
};
@@ -423,3 +425,53 @@ This lifecycle callback is run immediately before a file is parsed by Bun's bund
As input, it receives the file's contents and can optionally return new source code.
<Info>This callback can be called from any thread and so the napi module implementation must be thread-safe.</Info>
### onEnd
```ts
onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
```
Registers a callback to be run after the bundle is complete. The callback receives the [`BuildOutput`](/docs/bundler#outputs) object containing the build results, including output files and any build messages.
```ts title="index.ts" icon="/icons/typescript.svg"
const result = await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
plugins: [
{
name: "onEnd example",
setup(build) {
build.onEnd(result => {
console.log(`Build completed with ${result.outputs.length} files`);
for (const log of result.logs) {
console.log(log);
}
});
},
},
],
});
```
The callback can return a `Promise`. The build output promise from `Bun.build()` will not resolve until all `onEnd()` callbacks have completed.
```ts title="index.ts" icon="/icons/typescript.svg"
const result = await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
plugins: [
{
name: "Upload to S3",
setup(build) {
build.onEnd(async result => {
if (!result.success) return;
for (const output of result.outputs) {
await uploadToS3(output);
}
});
},
},
],
});
```

View 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

View File

@@ -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",

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -4,7 +4,7 @@ const TimerObjectInternals = @This();
/// Identifier for this timer that is exposed to JavaScript (by `+timer`)
id: i32 = -1,
interval: u31 = 0,
strong_this: jsc.Strong.Optional = .empty,
this_value: jsc.JSRef = .empty(),
flags: Flags = .{},
/// Used by:
@@ -76,31 +76,41 @@ pub fn runImmediateTask(this: *TimerObjectInternals, vm: *VirtualMachine) bool {
// loop alive other than setImmediates
(!this.flags.is_keeping_event_loop_alive and !vm.isEventLoopAliveExcludingImmediates()))
{
this.setEnableKeepingEventLoopAlive(vm, false);
this.this_value.downgrade();
this.deref();
return false;
}
const timer = this.strong_this.get() orelse {
const timer = this.this_value.tryGet() orelse {
if (Environment.isDebug) {
@panic("TimerObjectInternals.runImmediateTask: this_object is null");
}
this.setEnableKeepingEventLoopAlive(vm, false);
this.deref();
return false;
};
const globalThis = vm.global;
this.strong_this.deinit();
this.this_value.downgrade();
this.eventLoopTimer().state = .FIRED;
this.setEnableKeepingEventLoopAlive(vm, false);
timer.ensureStillAlive();
vm.eventLoop().enter();
const callback = ImmediateObject.js.callbackGetCached(timer).?;
const arguments = ImmediateObject.js.argumentsGetCached(timer).?;
this.ref();
const exception_thrown = this.run(globalThis, timer, callback, arguments, this.asyncID(), vm);
this.deref();
if (this.eventLoopTimer().state == .FIRED) {
this.deref();
}
const exception_thrown = brk: {
this.ref();
defer {
if (this.eventLoopTimer().state == .FIRED) {
this.deref();
}
this.deref();
}
break :brk this.run(globalThis, timer, callback, arguments, this.asyncID(), vm);
};
// --- after this point, the timer is no longer guaranteed to be alive ---
vm.eventLoop().exitMaybeDrainMicrotasks(!exception_thrown) catch return true;
@@ -120,7 +130,13 @@ pub fn fire(this: *TimerObjectInternals, _: *const timespec, vm: *jsc.VirtualMac
this.eventLoopTimer().state = .FIRED;
const globalThis = vm.global;
const this_object = this.strong_this.get().?;
const this_object = this.this_value.tryGet() orelse {
this.setEnableKeepingEventLoopAlive(vm, false);
this.flags.has_cleared_timer = true;
this.this_value.downgrade();
this.deref();
return;
};
const callback: JSValue, const arguments: JSValue, var idle_timeout: JSValue, var repeat: JSValue = switch (kind) {
.setImmediate => .{
@@ -143,7 +159,7 @@ pub fn fire(this: *TimerObjectInternals, _: *const timespec, vm: *jsc.VirtualMac
}
this.setEnableKeepingEventLoopAlive(vm, false);
this.flags.has_cleared_timer = true;
this.strong_this.deinit();
this.this_value.downgrade();
this.deref();
return;
@@ -152,7 +168,7 @@ pub fn fire(this: *TimerObjectInternals, _: *const timespec, vm: *jsc.VirtualMac
var time_before_call: timespec = undefined;
if (kind != .setInterval) {
this.strong_this.clearWithoutDeallocation();
this.this_value.downgrade();
} else {
time_before_call = timespec.msFromNow(.allow_mocked_time, this.interval);
}
@@ -239,7 +255,7 @@ fn convertToInterval(this: *TimerObjectInternals, global: *JSGlobalObject, timer
// https://github.com/nodejs/node/blob/a7cbb904745591c9a9d047a364c2c188e5470047/lib/internal/timers.js#L613
TimeoutObject.js.idleTimeoutSetCached(timer, global, repeat);
this.strong_this.set(global, timer);
this.this_value.setStrong(timer, global);
this.flags.kind = .setInterval;
this.interval = new_interval;
this.reschedule(timer, vm, global);
@@ -297,7 +313,7 @@ pub fn init(
this.reschedule(timer, vm, global);
}
this.strong_this.set(global, timer);
this.this_value.setStrong(timer, global);
}
pub fn doRef(this: *TimerObjectInternals, _: *jsc.JSGlobalObject, this_value: JSValue) JSValue {
@@ -327,7 +343,7 @@ pub fn doRefresh(this: *TimerObjectInternals, globalObject: *jsc.JSGlobalObject,
return this_value;
}
this.strong_this.set(globalObject, this_value);
this.this_value.setStrong(this_value, globalObject);
this.reschedule(this_value, VirtualMachine.get(), globalObject);
return this_value;
@@ -350,12 +366,18 @@ pub fn cancel(this: *TimerObjectInternals, vm: *VirtualMachine) void {
this.setEnableKeepingEventLoopAlive(vm, false);
this.flags.has_cleared_timer = true;
if (this.flags.kind == .setImmediate) return;
if (this.flags.kind == .setImmediate) {
// Release the strong reference so the GC can collect the JS object.
// The immediate task is still in the event loop queue and will be skipped
// by runImmediateTask when it sees has_cleared_timer == true.
this.this_value.downgrade();
return;
}
const was_active = this.eventLoopTimer().state == .ACTIVE;
this.eventLoopTimer().state = .CANCELLED;
this.strong_this.deinit();
this.this_value.downgrade();
if (was_active) {
vm.timer.remove(this.eventLoopTimer());
@@ -442,12 +464,12 @@ pub fn getDestroyed(this: *TimerObjectInternals) bool {
}
pub fn finalize(this: *TimerObjectInternals) void {
this.strong_this.deinit();
this.this_value.finalize();
this.deref();
}
pub fn deinit(this: *TimerObjectInternals) void {
this.strong_this.deinit();
this.this_value.deinit();
const vm = VirtualMachine.get();
const kind = this.flags.kind;

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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, {});
}
}

View File

@@ -1154,6 +1154,14 @@ pub const FetchTasklet = struct {
}
}
/// Whether the request body should skip chunked transfer encoding framing.
/// True for upgraded connections (e.g. WebSocket) or when the user explicitly
/// set Content-Length without setting Transfer-Encoding.
fn skipChunkedFraming(this: *const FetchTasklet) bool {
return this.upgraded_connection or
(this.request_headers.get("content-length") != null and this.request_headers.get("transfer-encoding") == null);
}
pub fn writeRequestData(this: *FetchTasklet, data: []const u8) ResumableSinkBackpressure {
log("writeRequestData {}", .{data.len});
if (this.signal) |signal| {
@@ -1175,7 +1183,7 @@ pub const FetchTasklet = struct {
// dont have backpressure so we will schedule the data to be written
// if we have backpressure the onWritable will drain the buffer
needs_schedule = stream_buffer.isEmpty();
if (this.upgraded_connection) {
if (this.skipChunkedFraming()) {
bun.handleOom(stream_buffer.write(data));
} else {
//16 is the max size of a hex number size that represents 64 bits + 2 for the \r\n
@@ -1209,15 +1217,14 @@ pub const FetchTasklet = struct {
}
this.abortTask();
} else {
if (!this.upgraded_connection) {
// If is not upgraded we need to send the terminating chunk
if (!this.skipChunkedFraming()) {
// Using chunked transfer encoding, send the terminating chunk
const thread_safe_stream_buffer = this.request_body_streaming_buffer orelse return;
const stream_buffer = thread_safe_stream_buffer.acquire();
defer thread_safe_stream_buffer.release();
bun.handleOom(stream_buffer.write(http.end_of_chunked_http1_1_encoding_response_body));
}
if (this.http) |http_| {
// just tell to write the end of the chunked encoding aka 0\r\n\r\n
http.http_thread.scheduleRequestWrite(http_, .end);
}
}

View File

@@ -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| {
@@ -68,6 +78,16 @@ pub const Chunk = struct {
}
pub fn getCSSChunkForHTML(this: *const Chunk, chunks: []Chunk) ?*Chunk {
// Look up the CSS chunk via the JS chunk's css_chunks indices.
// This correctly handles deduplicated CSS chunks that are shared
// across multiple HTML entry points (see issue #23668).
if (this.getJSChunkForHTML(chunks)) |js_chunk| {
const css_chunk_indices = js_chunk.content.javascript.css_chunks;
if (css_chunk_indices.len > 0) {
return &chunks[css_chunk_indices[0]];
}
}
// Fallback: match by entry_point_id for cases without a JS chunk.
const entry_point_id = this.entry_point.entry_point_id;
for (chunks) |*other| {
if (other.content == .css) {
@@ -127,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,
@@ -169,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,
),
};
}
@@ -184,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);
@@ -209,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];
@@ -283,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];

View File

@@ -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,

View File

@@ -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;
},
}

View File

@@ -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;
@@ -3683,7 +3697,20 @@ pub const BundleV2 = struct {
}
}
const import_record_loader = import_record.loader orelse path.loader(&transpiler.options.loaders) orelse .file;
const import_record_loader = brk: {
const resolved_loader = import_record.loader orelse path.loader(&transpiler.options.loaders) orelse .file;
// When an HTML file references a URL asset (e.g. <link rel="manifest" href="./manifest.json" />),
// the file must be copied to the output directory as-is. If the resolved loader would
// parse/transform the file (e.g. .json, .toml) rather than copy it, force the .file loader
// so that `shouldCopyForBundling()` returns true and the asset is emitted.
// Only do this for HTML sources — CSS url() imports should retain their original behavior.
if (loader == .html and import_record.kind == .url and !resolved_loader.shouldCopyForBundling() and
!resolved_loader.isJavaScriptLike() and !resolved_loader.isCSS() and resolved_loader != .html)
{
break :brk Loader.file;
}
break :brk resolved_loader;
};
import_record.loader = import_record_loader;
const is_html_entrypoint = import_record_loader == .html and target.isServerSide() and this.transpiler.options.dev_server == null;

View File

@@ -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 {

View File

@@ -22,7 +22,6 @@ pub noinline fn computeChunks(
const entry_source_indices = this.graph.entry_points.items(.source_index);
const css_asts = this.graph.ast.items(.css);
const css_chunking = this.options.css_chunking;
var html_chunks = bun.StringArrayHashMap(Chunk).init(temp_allocator);
const loaders = this.parse_graph.input_files.items(.loader);
const ast_targets = this.graph.ast.items(.target);
@@ -148,10 +147,11 @@ pub noinline fn computeChunks(
if (css_source_indices.len > 0) {
const order = this.findImportedFilesInCSSOrder(temp_allocator, css_source_indices.slice());
const use_content_based_key = css_chunking or has_server_html_imports;
const hash_to_use = if (!use_content_based_key)
bun.hash(try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len)))
else brk: {
// Always use content-based hashing for CSS chunk deduplication.
// This ensures that when multiple JS entry points import the
// same CSS files, they share a single CSS output chunk rather
// than producing duplicates that collide on hash-based naming.
const hash_to_use = brk: {
var hasher = std.hash.Wyhash.init(5);
bun.writeAnyToHasher(&hasher, order.len);
for (order.slice()) |x| x.hash(&hasher);
@@ -322,7 +322,10 @@ pub noinline fn computeChunks(
const remapped_css_indexes = try temp_allocator.alloc(u32, css_chunks.count());
const css_chunk_values = css_chunks.values();
for (sorted_css_keys, js_chunks.count()..) |key, sorted_index| {
// Use sorted_chunks.len as the starting index because HTML chunks
// may be interleaved with JS chunks, so js_chunks.count() would be
// incorrect when HTML entry points are present.
for (sorted_css_keys, sorted_chunks.len..) |key, sorted_index| {
const index = css_chunks.getIndex(key) orelse unreachable;
sorted_chunks.appendAssumeCapacity(css_chunk_values[index]);
remapped_css_indexes[index] = @intCast(sorted_index);

View File

@@ -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();

View File

@@ -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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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) {

View File

@@ -719,7 +719,21 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
if (body_len > 0 or this.method.hasRequestBody()) {
if (this.flags.is_streaming_request_body) {
if (add_transfer_encoding and this.flags.upgrade_state == .none) {
if (original_content_length) |content_length| {
if (add_transfer_encoding) {
// User explicitly set Content-Length and did not set Transfer-Encoding;
// preserve Content-Length instead of using chunked encoding.
// This matches Node.js behavior where an explicit Content-Length is always honored.
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = content_length,
};
header_count += 1;
}
// If !add_transfer_encoding, the user explicitly set Transfer-Encoding,
// which was already added to request_headers_buf. We respect that and
// do not add Content-Length (they are mutually exclusive per HTTP/1.1).
} else if (add_transfer_encoding and this.flags.upgrade_state == .none) {
request_headers_buf[header_count] = chunked_encoded_header;
header_count += 1;
}

View File

@@ -623,6 +623,17 @@ pub const PackageInstaller = struct {
// else => unreachable,
// };
// If a newly computed integrity hash is available (e.g. for a GitHub
// tarball) and the lockfile doesn't already have one, persist it so
// the lockfile gets re-saved with the hash.
if (data.integrity.tag.isSupported()) {
var pkg_metas = this.lockfile.packages.items(.meta);
if (!pkg_metas[package_id].integrity.tag.isSupported()) {
pkg_metas[package_id].integrity = data.integrity;
this.manager.options.enable.force_save_lockfile = true;
}
}
if (this.manager.task_queue.fetchRemove(task_id)) |removed| {
var callbacks = removed.value;
defer callbacks.deinit(this.manager.allocator);

View File

@@ -133,6 +133,12 @@ pub fn processExtractedTarballPackage(
break :package pkg;
};
// Store the tarball integrity hash so the lockfile can pin the
// exact content downloaded from the remote (GitHub) server.
if (data.integrity.tag.isSupported()) {
package.meta.integrity = data.integrity;
}
package = manager.lockfile.appendPackage(package) catch unreachable;
package_id.* = package.meta.id;

View File

@@ -23,7 +23,26 @@ pub inline fn run(this: *const ExtractTarball, log: *logger.Log, bytes: []const
return error.IntegrityCheckFailed;
}
}
return this.extract(log, bytes);
var result = try this.extract(log, bytes);
// Compute and store SHA-512 integrity hash for GitHub tarballs so the
// lockfile can pin the exact tarball content. On subsequent installs the
// hash stored in the lockfile is forwarded via this.integrity and verified
// above, preventing a compromised server from silently swapping the tarball.
if (this.resolution.tag == .github) {
if (this.integrity.tag.isSupported()) {
// Re-installing with an existing lockfile: integrity was already
// verified above, propagate the known value to ExtractData so that
// the lockfile keeps it on re-serialisation.
result.integrity = this.integrity;
} else {
// First install (no integrity in the lockfile yet): compute it.
result.integrity = .{ .tag = .sha512 };
Crypto.SHA512.hash(bytes, result.integrity.value[0..Crypto.SHA512.digest]);
}
}
return result;
}
pub fn buildURL(
@@ -547,6 +566,7 @@ const string = []const u8;
const Npm = @import("./npm.zig");
const std = @import("std");
const Crypto = @import("../sha.zig").Hashers;
const FileSystem = @import("../fs.zig").FileSystem;
const Integrity = @import("./integrity.zig").Integrity;
const Resolution = @import("./resolution.zig").Resolution;

View File

@@ -209,6 +209,7 @@ pub const ExtractData = struct {
path: string = "",
buf: []u8 = "",
} = null,
integrity: Integrity = .{},
};
pub const DependencyInstallContext = struct {
@@ -271,6 +272,7 @@ pub const VersionSlice = external.VersionSlice;
pub const Dependency = @import("./dependency.zig");
pub const Behavior = @import("./dependency.zig").Behavior;
pub const Integrity = @import("./integrity.zig").Integrity;
pub const Lockfile = @import("./lockfile.zig");
pub const PatchedDep = Lockfile.PatchedDep;

View File

@@ -644,9 +644,16 @@ pub const Stringifier = struct {
&path_buf,
);
try writer.print(", {f}]", .{
repo.resolved.fmtJson(buf, .{}),
});
if (pkg_meta.integrity.tag.isSupported()) {
try writer.print(", {f}, \"{f}\"]", .{
repo.resolved.fmtJson(buf, .{}),
pkg_meta.integrity,
});
} else {
try writer.print(", {f}]", .{
repo.resolved.fmtJson(buf, .{}),
});
}
},
else => unreachable,
}
@@ -1885,6 +1892,15 @@ pub fn parseIntoBinaryLockfile(
};
@field(res.value, @tagName(tag)).resolved = try string_buf.append(bun_tag_str);
// Optional integrity hash (added to pin tarball content)
if (i < pkg_info.len) {
const integrity_expr = pkg_info.at(i);
if (integrity_expr.asString(allocator)) |integrity_str| {
pkg.meta.integrity = Integrity.parse(integrity_str);
i += 1;
}
}
},
else => {},
}

View File

@@ -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

View File

@@ -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 || this[headerStateSymbol] !== NodeHTTPHeaderState.none) {
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);

View File

@@ -148,6 +148,11 @@ const SocketHandlers: SocketHandler = {
if (!self || self[kclosed]) return;
self[kclosed] = true;
//socket cannot be used after close
const callback = self[kwriteCallback];
if (callback) {
self[kwriteCallback] = null;
callback(err || new Error("Socket closed"));
}
detachSocket(self);
SocketEmitEndNT(self, err);
self.data = null;
@@ -314,6 +319,11 @@ const ServerHandlers: SocketHandler<NetSocket> = {
if (!data[kclosed]) {
data[kclosed] = true;
//socket cannot be used after close
const callback = data[kwriteCallback];
if (callback) {
data[kwriteCallback] = null;
callback(err || new Error("Socket closed"));
}
detachSocket(data);
SocketEmitEndNT(data, err);
data.data = null;

View File

@@ -766,19 +766,13 @@ pub extern fn napi_type_tag_object(env: napi_env, _: napi_value, _: [*c]const na
pub extern fn napi_check_object_type_tag(env: napi_env, _: napi_value, _: [*c]const napi_type_tag, _: *bool) napi_status;
// do nothing for both of these
pub export fn napi_open_callback_scope(env_: napi_env, _: napi_value, _: *anyopaque, _: *anyopaque) napi_status {
pub export fn napi_open_callback_scope(_: napi_env, _: napi_value, _: *anyopaque, _: *anyopaque) napi_status {
log("napi_open_callback_scope", .{});
const env = env_ orelse {
return envIsNull();
};
return env.ok();
return @intFromEnum(NapiStatus.ok);
}
pub export fn napi_close_callback_scope(env_: napi_env, _: *anyopaque) napi_status {
pub export fn napi_close_callback_scope(_: napi_env, _: *anyopaque) napi_status {
log("napi_close_callback_scope", .{});
const env = env_ orelse {
return envIsNull();
};
return env.ok();
return @intFromEnum(NapiStatus.ok);
}
pub extern fn napi_throw(env: napi_env, @"error": napi_value) napi_status;
pub extern fn napi_throw_error(env: napi_env, code: [*c]const u8, msg: [*c]const u8) napi_status;

View File

@@ -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 = "",

View File

@@ -12,24 +12,32 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
// Shared getter/setter functions: .bind(obj, key) avoids creating a closure
// and JSLexicalEnvironment per property. BoundFunction is much cheaper.
// Must be regular functions (not arrows) so .bind() can set `this`.
function __accessProp(key) {
return this[key];
}
// This is used to implement "export * from" statements. It copies properties
// from the imported module to the current module's ESM export object. If the
// current module is an entry point and the target format is CommonJS, we
// also copy the properties to "module.exports" in addition to our module's
// internal ESM export object.
export var __reExport = (target, mod, secondTarget) => {
for (let key of __getOwnPropNames(mod))
var keys = __getOwnPropNames(mod);
for (let key of keys)
if (!__hasOwnProp.call(target, key) && key !== "default")
__defProp(target, key, {
get: () => mod[key],
get: __accessProp.bind(mod, key),
enumerable: true,
});
if (secondTarget) {
for (let key of __getOwnPropNames(mod))
for (let key of keys)
if (!__hasOwnProp.call(secondTarget, key) && key !== "default")
__defProp(secondTarget, key, {
get: () => mod[key],
get: __accessProp.bind(mod, key),
enumerable: true,
});
@@ -37,11 +45,22 @@ export var __reExport = (target, mod, secondTarget) => {
}
};
/*__PURE__*/
var __toESMCache_node;
/*__PURE__*/
var __toESMCache_esm;
// Converts the module from CommonJS to ESM. When in node mode (i.e. in an
// ".mjs" file, package.json has "type: module", or the "__esModule" export
// in the CommonJS file is falsy or missing), the "default" property is
// overridden to point to the original CommonJS exports object instead.
export var __toESM = (mod, isNodeMode, target) => {
var canCache = mod != null && typeof mod === "object";
if (canCache) {
var cache = isNodeMode ? (__toESMCache_node ??= new WeakMap()) : (__toESMCache_esm ??= new WeakMap());
var cached = cache.get(mod);
if (cached) return cached;
}
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to =
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
@@ -53,34 +72,34 @@ export var __toESM = (mod, isNodeMode, target) => {
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: () => mod[key],
get: __accessProp.bind(mod, key),
enumerable: true,
});
if (canCache) cache.set(mod, to);
return to;
};
// Converts the module from ESM to CommonJS. This clones the input module
// object with the addition of a non-enumerable "__esModule" property set
// to "true", which overwrites any existing export named "__esModule".
var __moduleCache = /* @__PURE__ */ new WeakMap();
export var __toCommonJS = /* @__PURE__ */ from => {
var entry = __moduleCache.get(from),
export var __toCommonJS = from => {
var entry = (__moduleCache ??= new WeakMap()).get(from),
desc;
if (entry) return entry;
entry = __defProp({}, "__esModule", { value: true });
if ((from && typeof from === "object") || typeof from === "function")
__getOwnPropNames(from).map(
key =>
!__hasOwnProp.call(entry, key) &&
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: () => from[key],
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable,
}),
);
});
__moduleCache.set(from, entry);
return entry;
};
/*__PURE__*/
var __moduleCache;
// When you do know the module is CJS
export var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -97,6 +116,10 @@ export var __name = (target, name) => {
// ESM export -> CJS export
// except, writable incase something re-exports
var __returnValue = v => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
export var __export = /* @__PURE__ */ (target, all) => {
for (var name in all)
@@ -104,15 +127,19 @@ export var __export = /* @__PURE__ */ (target, all) => {
get: all[name],
enumerable: true,
configurable: true,
set: newValue => (all[name] = () => newValue),
set: __exportSetter.bind(all, name),
});
};
function __exportValueSetter(name, newValue) {
this[name] = newValue;
}
export var __exportValue = (target, all) => {
for (var name in all) {
__defProp(target, name, {
get: () => all[name],
set: newValue => (all[name] = newValue),
get: __accessProp.bind(all, name),
set: __exportValueSetter.bind(all, name),
enumerable: true,
configurable: true,
});

View File

@@ -2,13 +2,17 @@
exports[`Bun.build Bun.write(BuildArtifact) 1`] = `
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};
@@ -31,13 +35,17 @@ NS.then(({ fn: fn2 }) => {
exports[`Bun.build outdir + reading out blobs works 1`] = `
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};
@@ -58,23 +66,27 @@ NS.then(({ fn: fn2 }) => {
"
`;
exports[`Bun.build BuildArtifact properties: hash 1`] = `"d1c7nm6t"`;
exports[`Bun.build BuildArtifact properties: hash 1`] = `"est79qzq"`;
exports[`Bun.build BuildArtifact properties + entry.naming: hash 1`] = `"rm7e36cf"`;
exports[`Bun.build BuildArtifact properties + entry.naming: hash 1`] = `"7gfnt0h6"`;
exports[`Bun.build BuildArtifact properties sourcemap: hash index.js 1`] = `"d1c7nm6t"`;
exports[`Bun.build BuildArtifact properties sourcemap: hash index.js 1`] = `"est79qzq"`;
exports[`Bun.build BuildArtifact properties sourcemap: hash index.js.map 1`] = `"00000000"`;
exports[`Bun.build new Response(BuildArtifact) sets content type: response text 1`] = `
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};

View File

@@ -1113,7 +1113,7 @@ describe("bundler", () => {
snapshotSourceMap: {
"entry.js.map": {
files: ["../node_modules/react/index.js", "../entry.js"],
mappingsExactMatch: "qYACA,WAAW,IAAQ,EAAE,ICDrB,eACA,QAAQ,IAAI,CAAK",
mappingsExactMatch: "miBACA,WAAW,IAAQ,EAAE,ICDrB,eACA,QAAQ,IAAI,CAAK",
},
},
});

View File

@@ -843,4 +843,131 @@ body {
api.expectFile("out/" + jsFile).toContain("sourceMappingURL");
},
});
// Test that multiple HTML entrypoints sharing the same CSS file both get
// the CSS link tag in production mode (css_chunking deduplication).
// Regression test for https://github.com/oven-sh/bun/issues/23668
itBundled("html/SharedCSSProductionMultipleEntries", {
outdir: "out/",
production: true,
files: {
"/entry1.html": `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./global.css" />
</head>
<body>
<div id="root"></div>
<script src="./main1.tsx"></script>
</body>
</html>`,
"/entry2.html": `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./global.css" />
</head>
<body>
<div id="root"></div>
<script src="./main2.tsx"></script>
</body>
</html>`,
"/global.css": `h1 { font-size: 24px; }`,
"/main1.tsx": `console.log("entry1");`,
"/main2.tsx": `console.log("entry2");`,
},
entryPoints: ["/entry1.html", "/entry2.html"],
onAfterBundle(api) {
const entry1Html = api.readFile("out/entry1.html");
const entry2Html = api.readFile("out/entry2.html");
// Both HTML files must contain a CSS link tag
const cssMatch1 = entry1Html.match(/href="(.*\.css)"/);
const cssMatch2 = entry2Html.match(/href="(.*\.css)"/);
expect(cssMatch1).not.toBeNull();
expect(cssMatch2).not.toBeNull();
// Both should reference the same deduplicated CSS chunk
expect(cssMatch1![1]).toBe(cssMatch2![1]);
// The CSS file should contain the shared styles
const cssContent = api.readFile("out/" + cssMatch1![1]);
expect(cssContent).toContain("font-size");
// Both HTML files should also have their respective JS bundles
expect(entry1Html).toMatch(/src=".*\.js"/);
expect(entry2Html).toMatch(/src=".*\.js"/);
},
});
// Test manifest.json is copied as an asset and link href is rewritten
itBundled("html/manifest-json", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<head>
<link rel="manifest" href="./manifest.json" />
</head>
<body>
<h1>App</h1>
<script src="./app.js"></script>
</body>
</html>`,
"/manifest.json": JSON.stringify({
name: "My App",
short_name: "App",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
}),
"/app.js": "console.log('hello')",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const htmlContent = api.readFile("out/index.html");
// The original manifest.json reference should be rewritten to a hashed filename
expect(htmlContent).not.toContain('manifest.json"');
expect(htmlContent).toMatch(/href="(?:\.\/|\/)?manifest-[a-zA-Z0-9]+\.json"/);
// Extract the hashed manifest filename and verify its content
const manifestMatch = htmlContent.match(/href="(?:\.\/|\/)?(manifest-[a-zA-Z0-9]+\.json)"/);
expect(manifestMatch).not.toBeNull();
const manifestContent = api.readFile("out/" + manifestMatch![1]);
expect(manifestContent).toContain('"name"');
expect(manifestContent).toContain('"My App"');
},
});
// Test that other non-JS/CSS file types referenced via URL imports are copied as assets
itBundled("html/xml-asset", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<head>
<link rel="manifest" href="./site.webmanifest" />
</head>
<body>
<h1>App</h1>
</body>
</html>`,
"/site.webmanifest": JSON.stringify({
name: "My App",
icons: [{ src: "/icon.png", sizes: "192x192" }],
}),
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const htmlContent = api.readFile("out/index.html");
// The webmanifest reference should be rewritten to a hashed filename
expect(htmlContent).not.toContain("site.webmanifest");
expect(htmlContent).toMatch(/href=".*\.webmanifest"/);
},
});
});

View File

@@ -57,17 +57,17 @@ describe("bundler", () => {
"../entry.tsx",
],
mappings: [
["react.development.js:524:'getContextName'", "1:5412:Y1"],
["react.development.js:524:'getContextName'", "1:5567:Y1"],
["react.development.js:2495:'actScopeDepth'", "23:4082:GJ++"],
["react.development.js:696:''Component'", '1:7474:\'Component "%s"'],
["entry.tsx:6:'\"Content-Type\"'", '100:18809:"Content-Type"'],
["entry.tsx:11:'<html>'", "100:19063:void"],
["entry.tsx:23:'await'", "100:19163:await"],
["react.development.js:696:''Component'", '1:7629:\'Component "%s"'],
["entry.tsx:6:'\"Content-Type\"'", '100:18808:"Content-Type"'],
["entry.tsx:11:'<html>'", "100:19062:void"],
["entry.tsx:23:'await'", "100:19161:await"],
],
},
},
expectExactFilesize: {
"out/entry.js": 221720,
"out/entry.js": 221895,
},
run: {
stdout: "<!DOCTYPE html><html><body><h1>Hello World</h1><p>This is an example.</p></body></html>",

View File

@@ -76,13 +76,17 @@ describe("bundler", () => {
expect(bundled).toMatchInlineSnapshot(`
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -160,7 +164,7 @@ describe("bundler", () => {
var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint);
AsyncEntryPoint2();
//# debugId=5E85CC0956C6307964756E2164756E21
//# debugId=42062903F19477CF64756E2164756E21
//# sourceMappingURL=out.js.map
"
`);
@@ -337,13 +341,17 @@ describe("bundler", () => {
expect(bundled).toMatchInlineSnapshot(`
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -402,7 +410,7 @@ describe("bundler", () => {
var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint);
AsyncEntryPoint2();
//# debugId=C92CBF0103732ECC64756E2164756E21
//# debugId=BF876FBF618133C264756E2164756E21
//# sourceMappingURL=out.js.map
"
`);

View File

@@ -2150,10 +2150,7 @@ c {
toplevel-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
`, */
});
// TODO: Bun's bundler doesn't support multiple entry points generating CSS outputs
// with identical content hashes to the same output path. This test exposes that
// limitation. Skip until the bundler can deduplicate or handle this case.
itBundled.skip("css/MetafileCSSBundleTwoToOne", {
itBundled("css/MetafileCSSBundleTwoToOne", {
files: {
"/foo/entry.js": /* js */ `
import '../common.css'

View File

@@ -103,11 +103,11 @@ console.log(favicon);
"files": [
{
"input": "client.html",
"path": "./client-s249t5qg.js",
"path": "./client-b5m4ng86.js",
"loader": "js",
"isEntry": true,
"headers": {
"etag": "fxoJ6L-0X3o",
"etag": "Ax71YVYyZQc",
"content-type": "text/javascript;charset=utf-8"
}
},

View 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>");
});
});

View File

@@ -0,0 +1,255 @@
import { file } from "bun";
import { describe, expect, test } from "bun:test";
import { rm } from "fs/promises";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
// Each test uses its own BUN_INSTALL_CACHE_DIR inside the temp dir for full
// isolation. This avoids interfering with the global cache or other tests.
function envWithCache(dir: string) {
return { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") };
}
describe.concurrent("GitHub tarball integrity", () => {
test("should store integrity hash in lockfile for GitHub dependencies", async () => {
using dir = tempDir("github-integrity", {
"package.json": JSON.stringify({
name: "test-github-integrity",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
}),
});
const env = envWithCache(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Saved lockfile");
expect(exitCode).toBe(0);
const lockfileContent = await file(join(String(dir), "bun.lock")).text();
// The lockfile should contain a sha512 integrity hash for the GitHub dependency
expect(lockfileContent).toContain("sha512-");
// The resolved commit hash should be present
expect(lockfileContent).toContain("jonschlinkert-is-number-98e8ff1");
// Verify the format: the integrity appears after the resolved commit hash
expect(lockfileContent).toMatch(/"jonschlinkert-is-number-98e8ff1",\s*"sha512-/);
});
test("should verify integrity passes on re-install with matching hash", async () => {
using dir = tempDir("github-integrity-match", {
"package.json": JSON.stringify({
name: "test-github-integrity-match",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
}),
});
const env = envWithCache(dir);
// First install to generate lockfile with correct integrity
await using proc1 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
expect(stderr1).not.toContain("error:");
expect(exitCode1).toBe(0);
// Read the generated lockfile and extract the integrity hash adjacent to
// the GitHub resolved entry to avoid accidentally matching an npm hash.
const lockfileContent = await file(join(String(dir), "bun.lock")).text();
const integrityMatch = lockfileContent.match(/"jonschlinkert-is-number-98e8ff1",\s*"(sha512-[A-Za-z0-9+/]+=*)"/);
expect(integrityMatch).not.toBeNull();
const integrityHash = integrityMatch![1];
// Clear cache and node_modules, then re-install with the same lockfile
await rm(join(String(dir), ".bun-cache"), { recursive: true, force: true });
await rm(join(String(dir), "node_modules"), { recursive: true, force: true });
await using proc2 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
// Should succeed because the integrity matches
expect(stderr2).not.toContain("Integrity check failed");
expect(exitCode2).toBe(0);
// Lockfile should still contain the same integrity hash
const lockfileContent2 = await file(join(String(dir), "bun.lock")).text();
expect(lockfileContent2).toContain(integrityHash);
});
test("should reject GitHub tarball when integrity check fails", async () => {
using dir = tempDir("github-integrity-reject", {
"package.json": JSON.stringify({
name: "test-github-integrity-reject",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
}),
// Pre-create a lockfile with an invalid integrity hash (valid base64, 64 zero bytes)
"bun.lock": JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": {
name: "test-github-integrity-reject",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
},
},
packages: {
"is-number": [
"is-number@github:jonschlinkert/is-number#98e8ff1",
{},
"jonschlinkert-is-number-98e8ff1",
"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
],
},
}),
});
// Fresh per-test cache ensures the tarball must be downloaded from the network
const env = envWithCache(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Integrity check failed");
expect(exitCode).not.toBe(0);
});
test("should update lockfile with integrity when old format has none", async () => {
using dir = tempDir("github-integrity-upgrade", {
"package.json": JSON.stringify({
name: "test-github-integrity-upgrade",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
}),
// Pre-create a lockfile in the old format (no integrity hash)
"bun.lock": JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": {
name: "test-github-integrity-upgrade",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
},
},
packages: {
"is-number": ["is-number@github:jonschlinkert/is-number#98e8ff1", {}, "jonschlinkert-is-number-98e8ff1"],
},
}),
});
// Fresh per-test cache ensures the tarball must be downloaded
const env = envWithCache(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should succeed without errors
expect(stderr).not.toContain("Integrity check failed");
expect(stderr).not.toContain("error:");
// The lockfile should be re-saved with the new integrity hash
expect(stderr).toContain("Saved lockfile");
expect(exitCode).toBe(0);
// Verify the lockfile now contains the integrity hash
const lockfileContent = await file(join(String(dir), "bun.lock")).text();
expect(lockfileContent).toContain("sha512-");
expect(lockfileContent).toMatch(/"jonschlinkert-is-number-98e8ff1",\s*"sha512-/);
});
test("should accept GitHub dependency from cache without re-downloading", async () => {
// Use a shared cache dir for both installs so the second is a true cache hit
using dir = tempDir("github-integrity-cached", {
"package.json": JSON.stringify({
name: "test-github-integrity-cached",
dependencies: {
"is-number": "jonschlinkert/is-number#98e8ff1",
},
}),
});
const env = envWithCache(dir);
// First install warms the per-test cache
await using proc1 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
expect(stderr1).not.toContain("error:");
expect(exitCode1).toBe(0);
// Remove node_modules but keep the cache
await rm(join(String(dir), "node_modules"), { recursive: true, force: true });
// Strip the integrity from the lockfile to simulate an old-format lockfile
// that should still work when the cache already has the package
const lockfileContent = await file(join(String(dir), "bun.lock")).text();
const stripped = lockfileContent.replace(/,\s*"sha512-[^"]*"/, "");
await Bun.write(join(String(dir), "bun.lock"), stripped);
// Second install should hit the cache and succeed without re-downloading
await using proc2 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
// Should succeed without integrity errors (package served from cache)
expect(stderr2).not.toContain("Integrity check failed");
expect(stderr2).not.toContain("error:");
expect(exitCode2).toBe(0);
});
});

View File

@@ -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([]);

View File

@@ -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");
}
});

View 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);
});
});

View File

@@ -0,0 +1,138 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/24808
// When multiple server-side sockets have their remote clients disconnect while the server
// is actively writing data, ALL sockets should eventually emit the `close` event.
// Previously, only one socket would complete the full event lifecycle while the rest
// would stall after `end`, never emitting `close`.
test("all server sockets emit close when clients disconnect during active writes", async () => {
const NUM_CLIENTS = 4;
using dir = tempDir("24808", {
"server.js": `
const net = require('net');
const buffer = Buffer.allocUnsafeSlow(1024 * 128);
const NUM = ${NUM_CLIENTS};
let socketId = 0;
const closedSet = new Set();
const server = net.createServer((c) => {
const id = ++socketId;
let canwrite = true;
function write() {
if (!c.writable) return;
if (c.writableLength > 1024 * 1024 || !canwrite) return;
canwrite = c.write(buffer, (err) => {
if (err) return;
canwrite = true;
// Recursively write to keep the writable stream busy
write();
});
}
write();
const tt = setInterval(write, 1);
c.on("drain", write);
c.on("end", () => { clearInterval(tt); });
c.on("error", () => { clearInterval(tt); });
c.on("close", () => {
closedSet.add(id);
c.removeAllListeners("drain");
clearInterval(tt);
if (closedSet.size === NUM) {
console.log("CLOSED:" + JSON.stringify([...closedSet].sort()));
clearTimeout(failTimer);
server.close();
}
});
});
const failTimer = setTimeout(() => {
console.log("TIMEOUT");
console.log("CLOSED:" + JSON.stringify([...closedSet].sort()));
process.exit(1);
}, 10000);
server.listen(0, () => {
console.log("PORT:" + server.address().port);
});
`,
});
await using serverProc = Bun.spawn({
cmd: [bunExe(), "server.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Read port from server stdout
const reader = serverProc.stdout.getReader();
let portLine = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
portLine += new TextDecoder().decode(value);
if (portLine.includes("\n")) break;
}
const portMatch = portLine.match(/PORT:(\d+)/);
expect(portMatch).not.toBeNull();
const port = parseInt(portMatch![1]);
// Create clients that connect, don't read data (to build up server backpressure), then disconnect
await using clientProc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const net = require('net');
const sockets = [];
for (let i = 0; i < ${NUM_CLIENTS}; i++) {
const s = net.connect({ port: ${port}, host: '127.0.0.1' });
s.on('error', () => {});
sockets.push(s);
}
setTimeout(() => {
sockets.forEach(s => s.destroy());
setTimeout(() => process.exit(0), 500);
}, 2000);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait for server to finish
let remaining = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
remaining += new TextDecoder().decode(value);
}
reader.releaseLock();
const fullOutput = portLine + remaining;
const [stdout, stderr, exitCode] = await Promise.all([
Promise.resolve(fullOutput),
serverProc.stderr.text(),
serverProc.exited,
]);
await clientProc.exited;
const closedMatch = fullOutput.match(/CLOSED:(\[.*?\])/);
expect(closedMatch).not.toBeNull();
const closed = JSON.parse(closedMatch![1]);
// All sockets should have received the close event
expect(closed).toEqual([1, 2, 3, 4]);
expect(exitCode).toBe(0);
}, 15000);

View File

@@ -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!");
});

View File

@@ -0,0 +1,89 @@
import { expect, test } from "bun:test";
import http from "node:http";
test("ClientRequest.setHeaders should not throw ERR_HTTP_HEADERS_SENT on new request", async () => {
await using server = Bun.serve({
port: 0,
fetch(req) {
return new Response(req.headers.get("x-test") ?? "missing");
},
});
const { resolve, reject, promise } = Promise.withResolvers<string>();
const req = http.request(`http://localhost:${server.port}/test`, { method: "GET" }, res => {
let data = "";
res.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
res.on("end", () => resolve(data));
});
req.on("error", reject);
// This should not throw - headers haven't been sent yet
req.setHeaders(new Headers({ "x-test": "value" }));
req.end();
const body = await promise;
expect(body).toBe("value");
});
test("ClientRequest.setHeaders works with Map", async () => {
await using server = Bun.serve({
port: 0,
fetch(req) {
return new Response(req.headers.get("x-map-test") ?? "missing");
},
});
const { resolve, reject, promise } = Promise.withResolvers<string>();
const req = http.request(`http://localhost:${server.port}/test`, { method: "GET" }, res => {
let data = "";
res.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
res.on("end", () => resolve(data));
});
req.on("error", reject);
req.setHeaders(new Map([["x-map-test", "map-value"]]));
req.end();
const body = await promise;
expect(body).toBe("map-value");
});
test("ServerResponse.setHeaders should not throw before headers are sent", async () => {
const { resolve, reject, promise } = Promise.withResolvers<string>();
const server = http.createServer((req, res) => {
// This should not throw - headers haven't been sent yet
res.setHeaders(new Headers({ "x-custom": "server-value" }));
res.writeHead(200);
res.end("ok");
});
try {
server.listen(0, () => {
const port = (server.address() as any).port;
try {
const req = http.request(`http://localhost:${port}/test`, res => {
resolve(res.headers["x-custom"] as string);
});
req.on("error", reject);
req.end();
} catch (e) {
reject(e);
}
});
expect(await promise).toBe("server-value");
} finally {
server.close();
}
});

View File

@@ -0,0 +1,336 @@
import { describe, expect, test } from "bun:test";
import http from "node:http";
// Regression test for https://github.com/oven-sh/bun/issues/27061
// When http.ClientRequest.write() is called more than once (streaming data in chunks),
// Bun was stripping the explicitly-set Content-Length header and switching to
// Transfer-Encoding: chunked. Node.js preserves Content-Length in all cases.
describe("node:http ClientRequest preserves explicit Content-Length", () => {
test("with multiple req.write() calls", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const chunk1 = Buffer.alloc(100, "a");
const chunk2 = Buffer.alloc(100, "b");
const totalLength = chunk1.length + chunk2.length;
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
headers: {
"Content-Length": totalLength.toString(),
},
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(chunk1);
req.write(chunk2);
req.end();
});
const result = await promise;
expect(result.contentLength).toBe("200");
expect(result.transferEncoding).toBeUndefined();
expect(result.bodyLength).toBe(200);
} finally {
server.close();
}
});
test("with req.write() + req.end(data)", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const chunk1 = Buffer.alloc(100, "a");
const chunk2 = Buffer.alloc(100, "b");
const totalLength = chunk1.length + chunk2.length;
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
headers: {
"Content-Length": totalLength.toString(),
},
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(chunk1);
req.end(chunk2);
});
const result = await promise;
expect(result.contentLength).toBe("200");
expect(result.transferEncoding).toBeUndefined();
expect(result.bodyLength).toBe(200);
} finally {
server.close();
}
});
test("with three req.write() calls", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const chunk1 = Buffer.alloc(100, "a");
const chunk2 = Buffer.alloc(100, "b");
const chunk3 = Buffer.alloc(100, "c");
const totalLength = chunk1.length + chunk2.length + chunk3.length;
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
headers: {
"Content-Length": totalLength.toString(),
},
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(chunk1);
req.write(chunk2);
req.write(chunk3);
req.end();
});
const result = await promise;
expect(result.contentLength).toBe("300");
expect(result.transferEncoding).toBeUndefined();
expect(result.bodyLength).toBe(300);
} finally {
server.close();
}
});
test("single req.write() still works", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const data = Buffer.alloc(200, "x");
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
headers: {
"Content-Length": data.length.toString(),
},
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(data);
req.end();
});
const result = await promise;
expect(result.contentLength).toBe("200");
expect(result.transferEncoding).toBeUndefined();
expect(result.bodyLength).toBe(200);
} finally {
server.close();
}
});
test("without explicit Content-Length still uses chunked encoding", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const chunk1 = Buffer.alloc(100, "a");
const chunk2 = Buffer.alloc(100, "b");
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
// No Content-Length header
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(chunk1);
req.write(chunk2);
req.end();
});
const result = await promise;
// Without explicit Content-Length, chunked encoding should be used
expect(result.transferEncoding).toBe("chunked");
expect(result.bodyLength).toBe(200);
} finally {
server.close();
}
});
test("explicit Transfer-Encoding takes precedence over Content-Length", async () => {
const { promise, resolve } = Promise.withResolvers<{
contentLength: string | undefined;
transferEncoding: string | undefined;
bodyLength: number;
}>();
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
resolve({
contentLength: req.headers["content-length"],
transferEncoding: req.headers["transfer-encoding"],
bodyLength: Buffer.concat(chunks).length,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>(res => server.listen(0, "127.0.0.1", res));
const port = (server.address() as any).port;
try {
const chunk1 = Buffer.alloc(100, "a");
const chunk2 = Buffer.alloc(100, "b");
const req = http.request({
hostname: "127.0.0.1",
port,
method: "POST",
headers: {
"Content-Length": "200",
"Transfer-Encoding": "chunked",
},
});
await new Promise<void>((res, rej) => {
req.on("error", rej);
req.on("response", () => res());
req.write(chunk1);
req.write(chunk2);
req.end();
});
const result = await promise;
// When user explicitly sets Transfer-Encoding, it should be used
// and Content-Length should not be added
expect(result.transferEncoding).toBe("chunked");
expect(result.contentLength).toBeUndefined();
expect(result.bodyLength).toBe(200);
} finally {
server.close();
}
});
});

View File

@@ -92,13 +92,17 @@ test("cyclic imports with async dependencies should generate async wrappers", as
expect(bundled).toMatchInlineSnapshot(`
"var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
set: __exportSetter.bind(all, name)
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -176,7 +180,7 @@ test("cyclic imports with async dependencies should generate async wrappers", as
var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint);
AsyncEntryPoint2();
//# debugId=986E7BD819E590FD64756E2164756E21
//# debugId=2020261114B67BB564756E2164756E21
//# sourceMappingURL=entryBuild.js.map
"
`);