Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Keep runtime plugin resolution out of the content-addressed transpiler cache so changed plugin answers and temporary module generations cannot reuse stale import paths. Thanks @vincentkoc!
- Preserve child signals and inherited stdio in `bun run --silent` on macOS when startup marks descriptors close-on-exec.
- Publish Node-compatible `http.server.response.finish` diagnostics with request, response, socket, and server identities before advancing queued HTTP responses.
- Resolve `file:` URL preloads (`--preload`, `--import`, and Worker `execArgv`) like `import()` specifiers, so percent-encoded paths and Windows drive letters load instead of failing with "preload not found".
Expand Down
2 changes: 2 additions & 0 deletions src/bundler/linker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ impl Linker {
BunPluginTarget::Node
},
)? {
// Plugin answers are not part of the content-addressed cache key.
result.runtime_transpiler_cache = None;
import_record.path = self.generate_import_path(
source_dir,
path.text,
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ bun_core::declare_scope!(cache, visible);
/// Version 33: The cached ESM record keeps the namespace of an import that a plugin
/// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and
/// the cache-HIT path reinstates #33904 for them.
const EXPECTED_VERSION: u32 = 33;
/// Version 34: Discard output containing a previous runtime plugin's resolved import paths.
const EXPECTED_VERSION: u32 = 34;

/// Source files smaller than this are not written to / read from the on-disk
/// transpiler cache. Originally 50 KiB, which excluded almost every file in a
Expand Down
133 changes: 132 additions & 1 deletion test/cli/run/transpiler-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
unlinkSync,
writeFileSync,
} from "fs";
import { bunEnv, bunExe, bunRun, isWindows, tmpdirSync } from "harness";
import { bunEnv, bunExe, bunRun, isWindows, tempDir, tmpdirSync } from "harness";
import { mkfifo } from "mkfifo";
import { join } from "path";

Expand Down Expand Up @@ -136,6 +136,137 @@ describe("transpiler cache", () => {
expect(await bunRun(join(temp_dir, "b.js"), env)).toSpawn("b");
expect(newCacheCount()).toBe(0);
});

describe("runtime plugins", () => {
const filler = "\n//" + Buffer.alloc(5 * 1024, "f").toString();

test.each([
["import", "entry.mjs", 'import value from "./dependency.js"; export default value;'],
["reexport", "entry.mjs", 'export { default } from "./dependency.js";'],
["require", "entry.cjs", 'module.exports = require("./dependency.js");'],
])("does not retain a previous onResolve answer (%s)", async (_, entry, source) => {
using dir = tempDir("transpiler-cache-plugin", {
"package.json": '{"type":"module"}',
[entry]: source + filler,
"a.js": 'export default "A";',
"b.js": 'export default "B";',
"main.js": `
import { resolve } from "node:path";
let seen = false;
Bun.plugin({ name: "redirect", setup(build) {
build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, () => {
seen = true;
return { path: resolve(import.meta.dir, process.argv[2] + ".js") };
});
}});
const { default: value } = require(${JSON.stringify("./" + entry)});
console.log(JSON.stringify({ value, seen }));
`,
});
const cache = join(String(dir), ".cache");
const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache };
const run = (target: string) => bunRun([join(String(dir), "main.js"), target], childEnv);

expect(await run("a")).toSpawn(JSON.stringify({ value: "A", seen: true }));
expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true }));
unlinkSync(join(String(dir), "a.js"));
expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true }));
expect(existsSync(cache) ? readdirSync(cache) : []).toEqual([]);
});

test("resolves identical source in a new module generation", async () => {
const source = 'import value from "./dependency.js"; export default value;' + filler;
using dir = tempDir("transpiler-cache-generation", {
"package.json": '{"type":"module"}',
"a/entry.mjs": source,
"a/dependency.js": 'export default "A";',
"b/entry.mjs": source,
"b/dependency.js": 'export default "B";',
"main.js": `
import { dirname, resolve } from "node:path";
let seen = false;
Bun.plugin({ name: "generation", setup(build) {
build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, args => {
seen = true;
return { path: resolve(dirname(args.importer), args.path) };
});
}});
const { default: value } = require("./" + process.argv[2] + "/entry.mjs");
console.log(JSON.stringify({ value, seen }));
`,
});
const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), ".cache") };
const run = (generation: string) => bunRun([join(String(dir), "main.js"), generation], childEnv);
expect(await run("a")).toSpawn(JSON.stringify({ value: "A", seen: true }));
rmSync(join(String(dir), "a"), { recursive: true });
expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true }));
});

test.each(["absent", "declined"])("a cache populated with an %s hook uses the current resolver", async mode => {
using dir = tempDir("transpiler-cache-ordinary", {
"package.json": '{"type":"module"}',
"entry.mjs": 'import value from "./dependency.js"; export default value;' + filler,
"dependency.js": 'export default "A";',
"other.js": 'export default "B";',
"main.js": `
import { resolve } from "node:path";
let seen = false;
if (process.argv[2] !== "absent") {
Bun.plugin({ name: "optional", setup(build) {
build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, () => {
seen = true;
if (process.argv[2] !== "declined") return { path: resolve(import.meta.dir, "other.js") };
});
}});
}
const { default: value } = require("./entry.mjs");
console.log(JSON.stringify({ value, seen }));
`,
});
const cache = join(String(dir), ".cache");
const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache };
const run = (mode: string) => bunRun([join(String(dir), "main.js"), mode], childEnv);
expect(await run(mode)).toSpawn(JSON.stringify({ value: "A", seen: mode === "declined" }));
const entries = readdirSync(cache);
expect(entries).toHaveLength(1);
const filename = join(cache, entries[0]);
const original = readFileSync(filename);
expect(await run("claimed")).toSpawn(JSON.stringify({ value: "B", seen: true }));
expect(readdirSync(cache)).toEqual(entries);
expect(readFileSync(filename).equals(original)).toBeTrue();
});

test("does not cache an accepted onResolve answer that leaves the path unchanged", async () => {
using dir = tempDir("transpiler-cache-identity", {
"dependency.mjs": 'export default "A";',
"main.mjs": `
import { resolve } from "node:path";
const target = resolve(import.meta.dir, "dependency.mjs");
let seen = false;
Bun.plugin({ name: "identity", setup(build) {
build.onResolve({ filter: /.*/ }, args => {
if (args.path === target) {
seen = true;
return { path: args.path };
}
});
}});
const { default: value } = require("./entry.mjs");
console.log(JSON.stringify({ value, seen }));
`,
});
writeFileSync(
join(String(dir), "entry.mjs"),
`export { default } from ${JSON.stringify(join(String(dir), "dependency.mjs"))};${filler}`,
);
const cache = join(String(dir), ".cache");
expect(await bunRun(join(String(dir), "main.mjs"), { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache })).toSpawn(
JSON.stringify({ value: "A", seen: true }),
);
expect(existsSync(cache) ? readdirSync(cache) : []).toEqual([]);
});
});

test("doing 50 buns at once does not crash", async () => {
writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "b"));
writeFileSync(join(temp_dir, "b.js"), dummyFile(50 * 1024, "2", "b"));
Expand Down
18 changes: 15 additions & 3 deletions test/cli/test/isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,24 @@ describe.concurrent("bun test --isolate", () => {
expect(exitCode).toBe(0);
});

// The on-disk transpiler cache stores the module record next to the output. reexport-clause.ts
// is padded past the 4 KiB floor of that cache, so the second run rebuilds its record from the entry.
test("with --isolate, the on-disk transpiler cache keeps that namespace in the stored module record", async () => {
// Only the source-written namespace is cacheable; runtime onResolve answers must be linked afresh.
test("with --isolate, the disk cache preserves source namespaces but excludes plugin-rewritten imports", async () => {
using dir = tempDir("isolate-plugin-namespace-disk-cache", {
...pluginNamespaceFixture,
"reexport-clause.ts": `export { named } from "./data.bar?custom";\n//${Buffer.alloc(5 * 1024, "f").toString()}\n`,
"cache-namespace.ts": `export { named } from "cache-only:stable";\n//${Buffer.alloc(5 * 1024, "f").toString()}\n`,
"plugin.ts": `${pluginNamespaceFixture["plugin.ts"]}
Bun.plugin({ name: "cache-namespace", setup(build) {
build.onLoad({ filter: /.*/, namespace: "cache-only" }, () => ({
contents: 'export const named = "CACHED_NAMESPACE";',
loader: "js",
}));
}});
`,
"a.test.ts": `${pluginNamespaceTestFile}
import { named as cached } from "./cache-namespace.ts";
expect(cached).toBe("CACHED_NAMESPACE");
`,
});
const cacheDir = join(String(dir), ".cache");
const env = {
Expand Down
Loading