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
2 changes: 2 additions & 0 deletions docs/runtime/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ The callback receives the _path_ to the matching module and can return a _new pa

At runtime, the callback runs once for each `import` or `require()` as it executes. A _new path_ without a `namespace` is resolved from the importing module like any other import, without running `onResolve()` callbacks on it: it can be relative, leave out its extension, or name a package. If nothing is found there, the _new path_ is used as it is when an [`onLoad()`](#onload) callback matches it, so it does not have to exist on disk.

A bare name such as `"my-virtual.js"` could also be a package in the registry, so `onResolve()` callbacks run on it once more. If one of them returns a path, the name is the plugin's own and is never [auto-installed](/runtime/auto-install).

For example, redirecting all imports to `images/` to `./public/images/`:

```ts index.ts icon="/icons/typescript.svg"
Expand Down
4 changes: 4 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6394,6 +6394,10 @@ declare module "bun" {
* importing module like any other import, without running `onResolve`
* callbacks on it. If nothing is found there, it is used as it is when an
* `onLoad` callback matches it.
*
* A bare name could also be a package in the registry, so `onResolve`
* callbacks run on it once more. If one of them returns a path, the name is
* the plugin's own and is never auto-installed.
*/
path: string;
/**
Expand Down
25 changes: 24 additions & 1 deletion src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5035,6 +5035,7 @@ impl VirtualMachine {
source: &[u8],
is_esm: bool,
is_a_file_path: bool,
global_cache: bun_resolver::GlobalCache,
) -> crate::CrateResult<()> {
use bun_js_parser::Macro;
use bun_resolver::{ResultUnion, node_fallbacks};
Expand Down Expand Up @@ -5124,7 +5125,6 @@ impl VirtualMachine {
} else {
bun_ast::ImportKind::Require
};
let global_cache = self.transpiler.resolver.opts.global_cache;
match self.transpiler.resolver.resolve_and_auto_install(
source_to_use,
normalized_specifier,
Expand Down Expand Up @@ -5226,12 +5226,32 @@ impl VirtualMachine {
if let Some(name) = global.resolve_virtual_module(&answer, source) {
return Ok(Ok(name));
}
// A bare name may be a package's in the registry. It is the plugin's own, and not
// installed, if `onResolve` answers about it as well.
let answer_utf8 = answer.to_utf8();
let is_bare = bun_resolver::is_package_path(&answer_utf8)
&& ModuleLoader::plugin_namespace_and_path(&answer_utf8)
.is_some_and(|(namespace, _)| namespace.is_empty());
drop(answer_utf8);
let is_own = is_bare
&& (answer.eql(specifier)
|| match run_on_resolve(global, &answer, source)? {
None => false,
Some(Ok(_)) => true,
Some(Err(error)) => return Ok(Err(error)),
});
let global_cache = if is_own {
bun_resolver::GlobalCache::disable
} else {
global.bun_vm().transpiler.resolver.opts.global_cache
};
let resolved = Self::resolve_without_on_resolve::<IS_A_FILE_PATH>(
global,
&answer,
source,
query_string,
mode,
global_cache,
)?;
// Not on disk, for an `onLoad` to serve.
if resolved.is_err() && global.has_on_load(&answer.to_utf8())? {
Expand All @@ -5247,6 +5267,7 @@ impl VirtualMachine {
source,
query_string,
mode,
global.bun_vm().transpiler.resolver.opts.global_cache,
)
}

Expand All @@ -5256,6 +5277,7 @@ impl VirtualMachine {
source: &bun_core::String,
query_string: Option<&mut bun_core::String>,
mode: ResolveMode,
global_cache: bun_resolver::GlobalCache,
) -> JsResult<Result<bun_core::String, JSValue>> {
const MAX_LEN: usize = (bun_paths::MAX_PATH_BYTES as f64 * 1.5) as usize;
// `data:` URLs carry the module source inline and never touch the
Expand Down Expand Up @@ -5401,6 +5423,7 @@ impl VirtualMachine {
normalize_source(source_utf8.slice()),
mode.is_esm(),
IS_A_FILE_PATH,
global_cache,
);
if let Err(err_) = resolve_result {
let err = err_;
Expand Down
165 changes: 165 additions & 0 deletions test/js/bun/plugin/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,171 @@ describe.concurrent("what onResolve answers without a namespace", () => {
});
});

// Without a node_modules directory, Bun installs the package it does not find.
describe.concurrent("the registry is not asked for a bare name that onResolve answers about as well", () => {
// onResolve answers the first with itself, so it is not asked again. It is about the name it answers the second with.
const asksOnResolve = "onResolve itself-served.js\nonResolve dir/served.virtual\nonResolve served.virtual\n";
it.each([
Comment thread
dylan-conway marked this conversation as resolved.
[
"an import statement",
"entry.mjs",
`import a from "itself-served.js"; import b from "dir/served.virtual"; console.log(a, b);`,
],
[
"import()",
"entry.mjs",
`console.log((await import("itself-served.js")).default, (await import("dir/served.virtual")).default);`,
],
[
"require()",
"entry.cjs",
`console.log(require("itself-served.js").default, require("dir/served.virtual").default);`,
],
[
"import.meta.require()",
"entry.mjs",
`console.log(import.meta.require("itself-served.js").default, import.meta.require("dir/served.virtual").default);`,
],
[
"require.resolve()",
"entry.cjs",
`console.log(require.resolve("itself-served.js"), require.resolve("dir/served.virtual"));`,
],
[
"import.meta.resolve()",
"entry.mjs",
`console.log(import.meta.resolve("itself-served.js"), import.meta.resolve("dir/served.virtual"));`,
],
[
"Bun.resolveSync()",
"entry.mjs",
`console.log(Bun.resolveSync("itself-served.js", import.meta.dir), Bun.resolveSync("dir/served.virtual", import.meta.dir));`,
],
[
"Bun.resolve()",
"entry.mjs",
`console.log(await Bun.resolve("itself-served.js", import.meta.dir), await Bun.resolve("dir/served.virtual", import.meta.dir));`,
],
])("by %s", async (_, name, source) => {
expect(await run(name, source)).toEqual({
stdout: asksOnResolve + "itself-served.js served.virtual\n",
stderr: "",
exitCode: 0,
asked: ["/not-answered"],
});
});

it("whether or not an onLoad serves it", async () => {
const source = `
for (const specifier of ["itself-not-served.js", "dir/not-served.virtual"])
try { require.resolve(specifier); } catch (error) { console.log(error.message.split("\\n")[0]); }
`;
expect(await run("entry.cjs", source)).toEqual({
stdout:
"onResolve itself-not-served.js\nCannot find module 'itself-not-served.js'\n" +
"onResolve dir/not-served.virtual\nonResolve not-served.virtual\nCannot find module 'not-served.virtual'\n",
stderr: "",
exitCode: 0,
asked: ["/not-answered"],
});
});

it("and is for one it does not, though the filter of an onLoad or of an onResolve that declines matches", async () => {
const source = `
for (const specifier of ["package.redirect", "file.redirect", "file.declines"])
try { console.log(require.resolve(specifier)); } catch (error) { console.log(error.message.split("\\n")[0]); }
`;
expect(await run("entry.cjs", source)).toEqual({
stdout:
"onResolve package.redirect\nCannot find module 'a-package'\n" +
"onResolve file.redirect\nb-package/file.transformed\n" +
"onResolve file.declines\nonResolve c-package/file.declines\nCannot find module 'c-package/file.declines'\n",
stderr: "",
exitCode: 0,
asked: ["/a-package", "/b-package", "/c-package", "/not-answered"],
});
});

it("and onResolve is not asked again about an answer in a namespace", async () => {
expect(await run("entry.cjs", `console.log(require("moved.namespace").default);`)).toEqual({
stdout: "onResolve moved.namespace\ninner.js\n",
stderr: "",
exitCode: 0,
asked: ["/not-answered"],
});
});

it("and what onResolve says about the bare name is an error if it is not valid", async () => {
const source = `try { require.resolve("first.invalid"); } catch (error) { console.log(error.message); }`;
expect(await run("entry.cjs", source)).toEqual({
stdout: `onResolve first.invalid\nonResolve second.invalid\nExpected "path" to be a string in onResolve plugin\n`,
stderr: "",
exitCode: 0,
asked: ["/not-answered"],
});
});

async function run(name: string, source: string) {
const asked: string[] = [];
using registry = Bun.serve({
port: 0,
fetch(request) {
asked.push(new URL(request.url).pathname);
return new Response("{}", { status: 404 });
},
});
using dir = tempDir("plugin-onresolve-registry", {
"plugin.ts": `
import { basename } from "node:path";
const redirects = { "package.redirect": "a-package", "file.redirect": "b-package/file.transformed" };
function logged(answer) {
return args => (console.log("onResolve", args.path), answer(args));
}
Bun.plugin({
name: "answers",
setup(build) {
build.onResolve({ filter: /^itself-/ }, logged(({ path }) => ({ path })));
build.onResolve({ filter: /\\.virtual$/ }, logged(({ path }) => ({ path: basename(path) })));
build.onResolve({ filter: /\\.redirect$/ }, logged(({ path }) => ({ path: redirects[path] })));
build.onResolve({ filter: /\\.declines$/ }, logged(({ path }) => (path === "file.declines" ? { path: "c-package/" + path } : undefined)));
build.onResolve({ filter: /\\.namespace$/ }, logged(() => ({ path: "inner.js", namespace: "custom" })));
build.onResolve({ filter: /.*/, namespace: "custom" }, logged(() => undefined));
build.onResolve({ filter: /\\.invalid$/ }, logged(({ path }) => ({ path: path === "first.invalid" ? "second.invalid" : 42 })));
build.onLoad({ filter: /.*/, namespace: "custom" }, ({ path }) => ({
contents: "export default " + JSON.stringify(path),
loader: "js",
}));
build.onLoad({ filter: /^(itself-served\\.js|served\\.virtual)$/ }, ({ path }) => ({
contents: "export default " + JSON.stringify(path),
loader: "js",
}));
build.onLoad({ filter: /\\.transformed$/ }, async ({ path }) => ({
contents: await Bun.file(path).text(),
loader: "js",
}));
},
});
`,
// The last line is what no plugin answers about, which is asked of the registry.
[name]: source + `\nimport("not-answered").catch(() => {});`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--preload", "./plugin.ts", name],
cwd: String(dir),
env: {
...bunEnv,
BUN_CONFIG_REGISTRY: registry.url.href,
NPM_CONFIG_REGISTRY: registry.url.href,
BUN_INSTALL_CACHE_DIR: resolve(String(dir), ".cache"),
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode, asked };
}
});

it.concurrent("an onLoad in the namespace of builtins leaves their aliases alone", async () => {
const source = `
Bun.plugin({ name: "node", setup(build) { build.onLoad({ filter: /^never$/, namespace: "node" }, () => {}); } });
Expand Down
Loading