Add namespace and loader properties to runtime plugin onLoad callbacks

Fixes #3894

Previously, runtime plugins (using `plugin()` API) only received `path`
in their onLoad callbacks, making them much less capable than Bun.build()
plugins which receive `path`, `namespace`, and `loader`.

This change adds:
- **namespace** property - allows plugins to distinguish between different
  virtual module namespaces
- **loader** property - provides default loader based on file extension
  (js, tsx, jsx, json, text, toml, file)

This enables better plugin composition and makes it possible to:
- Pass information from onResolve to onLoad via namespace
- Handle query strings in imports (workaround for missing pluginData)
- Know the default loader for conditional handling

Example usage:
```typescript
plugin({
  setup(builder) {
    builder.onResolve({ filter: /\.mdx/ }, (args) => {
      const [path, query] = args.path.split("?");
      // Store query data externally since pluginData doesn't work yet
      queryMap.set(path, parseQuery(query));
      return { path, namespace: "mdx-loader" };
    });

    builder.onLoad({ namespace: "mdx-loader" }, (args) => {
      // Can now access namespace and retrieve query data
      const query = queryMap.get(args.path);
      // ... handle MDX with query parameters
    });
  }
});
```

**Still missing** (future work):
- pluginData property (would require tracking through resolve/load pipeline)
- suffix property (esbuild compatibility)
- defer, side properties (build-specific)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-11-01 08:31:12 +00:00
parent 0564b81e64
commit d8e57cd4c1
2 changed files with 260 additions and 1 deletions

View File

@@ -729,11 +729,46 @@ EncodedJSValue BunPlugin::OnLoad::run(JSC::JSGlobalObject* globalObject, BunStri
auto scope = DECLARE_THROW_SCOPE(vm);
scope.assertNoExceptionExceptTermination();
JSC::JSObject* paramsObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1);
JSC::JSObject* paramsObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 3);
const auto& builtinNames = WebCore::builtinNames(vm);
// Add path property
paramsObject->putDirect(
vm, builtinNames.pathPublicName(),
jsString(vm, pathString));
// Add namespace property if it exists
if (namespaceString) {
auto namespaceWTFString = namespaceString->toWTFString(BunString::ZeroCopy);
if (!namespaceWTFString.isEmpty()) {
paramsObject->putDirect(
vm, JSC::Identifier::fromString(vm, "namespace"_s),
jsString(vm, namespaceWTFString));
}
}
// Add loader property (default based on file extension)
// This matches the behavior of Bun.build() plugins
WTF::String loaderName;
if (pathString.endsWith(".js"_s) || pathString.endsWith(".cjs"_s) || pathString.endsWith(".mjs"_s)) {
loaderName = "js"_s;
} else if (pathString.endsWith(".ts"_s) || pathString.endsWith(".tsx"_s) || pathString.endsWith(".mts"_s) || pathString.endsWith(".cts"_s)) {
loaderName = "tsx"_s;
} else if (pathString.endsWith(".jsx"_s)) {
loaderName = "jsx"_s;
} else if (pathString.endsWith(".json"_s)) {
loaderName = "json"_s;
} else if (pathString.endsWith(".txt"_s)) {
loaderName = "text"_s;
} else if (pathString.endsWith(".toml"_s)) {
loaderName = "toml"_s;
} else {
loaderName = "file"_s;
}
paramsObject->putDirect(
vm, JSC::Identifier::fromString(vm, "loader"_s),
jsString(vm, loaderName));
arguments.append(paramsObject);
auto result = AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments);

View File

@@ -0,0 +1,224 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Tests for https://github.com/oven-sh/bun/issues/3894
// Runtime plugin onLoad should receive namespace, loader, and other properties
test("runtime plugin onLoad receives namespace property", async () => {
using dir = tempDir("runtime-plugin-namespace", {
"test.ts": /* ts */ `
import { plugin } from "bun";
plugin({
name: "namespace-test",
setup(builder) {
builder.onResolve({ filter: /\.custom$/ }, (args) => {
return {
path: args.path,
namespace: "custom-namespace",
};
});
builder.onLoad({ filter: /.*/, namespace: "custom-namespace" }, (args) => {
return {
exports: {
namespace: args.namespace,
path: args.path,
},
loader: "object",
};
});
},
});
const result = await import("./test.custom");
console.log(JSON.stringify(result));
`,
"test.custom": "dummy file",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.namespace).toBe("custom-namespace");
expect(result.path).toBe("./test.custom");
expect(stderr).not.toContain("undefined");
expect(exitCode).toBe(0);
});
test("runtime plugin onLoad receives loader property based on file extension", async () => {
using dir = tempDir("runtime-plugin-loader", {
"test.ts": /* ts */ `
import { plugin } from "bun";
plugin({
name: "loader-test",
setup(builder) {
builder.onResolve({ filter: /\.js$/ }, (args) => {
return {
path: args.path,
namespace: "loader-test",
};
});
builder.onLoad({ filter: /.*/, namespace: "loader-test" }, (args) => {
console.log(JSON.stringify({ loader: args.loader }));
return {
exports: {},
loader: "object",
};
});
},
});
await import("./test.js");
`,
"test.js": "",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.loader).toBe("js");
expect(exitCode).toBe(0);
});
test("runtime plugin with query string uses namespace workaround", async () => {
using dir = tempDir("plugin-query-namespace", {
"test.ts": /* ts */ `
import { plugin } from "bun";
const queryMap = new Map();
plugin({
name: "query-namespace-test",
setup(builder) {
builder.onResolve({ filter: /\.custom/ }, (args) => {
const [path, query = ""] = args.path.split("?");
const parsed = Object.fromEntries(new URLSearchParams(query));
// Store query data (workaround for missing pluginData)
if (Object.keys(parsed).length > 0) {
queryMap.set(path, parsed);
}
return {
path,
namespace: "custom",
};
});
builder.onLoad({ filter: /.*/, namespace: "custom" }, (args) => {
const queryData = queryMap.get(args.path) || {};
return {
exports: {
namespace: args.namespace,
loader: args.loader,
queryData,
},
loader: "object",
};
});
},
});
const result = await import("./test.custom?type=example&id=123");
console.log(JSON.stringify(result));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.namespace).toBe("custom");
expect(result.loader).toBe("file");
expect(result.queryData).toEqual({ type: "example", id: "123" });
expect(exitCode).toBe(0);
});
test("runtime plugin onLoad receives all properties", async () => {
using dir = tempDir("runtime-plugin-all-props", {
"test.ts": /* ts */ `
import { plugin } from "bun";
plugin({
name: "all-props-test",
setup(builder) {
builder.onResolve({ filter: /file\.ts$/ }, (args) => {
return {
path: args.path,
namespace: "test-namespace",
};
});
builder.onLoad({ filter: /.*/, namespace: "test-namespace" }, (args) => {
const props = {
hasPath: "path" in args,
hasNamespace: "namespace" in args,
hasLoader: "loader" in args,
namespace: args.namespace,
loader: args.loader,
allKeys: Object.keys(args).sort(),
};
console.log(JSON.stringify(props));
return {
exports: {},
loader: "object",
};
});
},
});
await import("./file.ts");
`,
"file.ts": "export default 42;",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const props = JSON.parse(stdout.trim());
expect(props.hasPath).toBe(true);
expect(props.hasNamespace).toBe(true);
expect(props.hasLoader).toBe(true);
expect(props.namespace).toBe("test-namespace");
expect(props.loader).toBe("tsx");
expect(props.allKeys).toEqual(["loader", "namespace", "path"]);
expect(exitCode).toBe(0);
});