mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 10:58:56 +00:00
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:
@@ -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);
|
||||
|
||||
224
test/js/bun/plugin/runtime-plugin-properties.test.ts
Normal file
224
test/js/bun/plugin/runtime-plugin-properties.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user