From d8e57cd4c19ec113617c9f7144244ea5d5214574 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 1 Nov 2025 08:31:12 +0000 Subject: [PATCH] Add namespace and loader properties to runtime plugin onLoad callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/bun.js/bindings/BunPlugin.cpp | 37 ++- .../plugin/runtime-plugin-properties.test.ts | 224 ++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/plugin/runtime-plugin-properties.test.ts diff --git a/src/bun.js/bindings/BunPlugin.cpp b/src/bun.js/bindings/BunPlugin.cpp index c7f463c0da..0a52604545 100644 --- a/src/bun.js/bindings/BunPlugin.cpp +++ b/src/bun.js/bindings/BunPlugin.cpp @@ -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); diff --git a/test/js/bun/plugin/runtime-plugin-properties.test.ts b/test/js/bun/plugin/runtime-plugin-properties.test.ts new file mode 100644 index 0000000000..6314ed7634 --- /dev/null +++ b/test/js/bun/plugin/runtime-plugin-properties.test.ts @@ -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); +});