Skip to content
Open
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
21 changes: 14 additions & 7 deletions src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2220,7 +2220,7 @@ pub mod parse_worker {
.unwrap_or(Loader::File);

let mut contents_came_from_plugin: bool = false;
let result = get_code_for_parse_task(
let entry = match get_code_for_parse_task(
task,
log,
transpiler,
Expand All @@ -2229,12 +2229,19 @@ pub mod parse_worker {
&mut file_path,
&mut loader,
&mut contents_came_from_plugin,
);
if result.is_err() {
// SAFETY: `transpiler` is live; no other borrow of it is held here.
unsafe { (*transpiler).reset_store() };
}
result
) {
Ok(entry) => entry,
Err(e) => {
// SAFETY: `transpiler` is live; no other borrow of it is held here.
unsafe { (*transpiler).reset_store() };
return Err(e);
}
};
// An onBeforeParse plugin may have chosen a different loader for the
// source it handed back. `run_with_source_code` (the next stage, possibly
// on another thread) re-derives the loader from `task`, so store it there.
task.loader = Some(loader);
Ok(entry)
}

// ───────────────────────────────────────────────────────────────────────────
Expand Down
21 changes: 20 additions & 1 deletion src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7113,7 +7113,26 @@ pub mod bv2_impl {
}

// Record which loader we used for this file
this.graph.input_files.items_loader_mut()[result_source_index] = result.loader;
let enqueued_loader = core::mem::replace(
&mut this.graph.input_files.items_loader_mut()[result_source_index],
result.loader,
);
// A native onBeforeParse plugin can switch loaders on the parse
// thread. `process_resolve_queue` & co. already did the
// copy-for-bundling bookkeeping for `enqueued_loader`, so redo it
// if the plugin moved the file into or out of that class.
if result.loader.should_copy_for_bundling()
!= enqueued_loader.should_copy_for_bundling()
{
let side_effects = if result.loader.should_copy_for_bundling() {
this.graph.estimated_file_loader_count += 1;
bun_ast::SideEffects::NoSideEffectsPureData
} else {
result.side_effects
};
this.graph.input_files.items_side_effects_mut()[result_source_index] =
side_effects;
}

bun_core::scoped_log!(
Bundle,
Expand Down
163 changes: 163 additions & 0 deletions test/bundler/native-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,169 @@ console.log(JSON.stringify(json))
expect(exitCode).toBe(0);
});

// `OnBeforeParseResult.loader` lets the plugin choose how Bun parses the file,
// overriding the loader implied by the extension.
describe("result loader", () => {
async function buildWithLoaderPlugin(opts: {
dir: string;
files: Record<string, string>;
entrypoint: string;
filter: RegExp;
symbol: string;
external?: unknown;
}) {
const dir = path.join(tempdir, "loader_tests", opts.dir);
await Promise.all(
Object.entries(opts.files).map(([name, contents]) => Bun.write(path.join(dir, name), contents)),
);
const napiModule = loadPlugin();

const result = await Bun.build({
outdir: path.join(dir, "out"),
entrypoints: [path.join(dir, opts.entrypoint)],
target: "bun",
plugins: [
{
name: "loader-test",
setup(build) {
build.onBeforeParse(
{ filter: opts.filter },
{ napiModule, symbol: opts.symbol, external: opts.external },
);
},
},
],
});
expect(result.logs).toBeEmpty();
expect(result.success).toBeTrue();
return result;
}

function loadPlugin() {
return require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
}

async function run(entrypoint: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), entrypoint],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
return stdout;
}

it("parses replaced source with the loader the plugin set", async () => {
const napiModule = loadPlugin();
const external = napiModule.createExternal();
const result = await buildWithLoaderPlugin({
dir: "compile_to_js_import",
files: {
"index.ts": `import message from "./message.foo";\nconsole.log(message);\n`,
"message.foo": "hello from foo",
},
entrypoint: "index.ts",
filter: /\.foo$/,
symbol: "plugin_impl_compile_to_js",
external,
});

// With the extension's `file` loader, message.foo would be copied to the
// outdir as an asset and the import would evaluate to its path.
expect(result.outputs.map(output => output.kind)).toEqual(["entry-point"]);
expect(await run(result.outputs[0].path)).toBe("hello from foo\n");
expect(napiModule.getCompilationCtxFreedCount(external)).toBe(1);
});

it("parses a replaced entry point with the loader the plugin set", async () => {
const result = await buildWithLoaderPlugin({
dir: "compile_to_js_entry",
files: {
"page.foo": "hello from the entry point",
},
entrypoint: "page.foo",
filter: /\.foo$/,
symbol: "plugin_impl_compile_to_js",
});

expect(result.outputs.map(output => [output.kind, path.basename(output.path)])).toEqual([
["entry-point", "page.js"],
]);
expect(await result.outputs[0].text()).toContain('"hello from the entry point"');
});

it("parses the original source with the loader the plugin set", async () => {
const result = await buildWithLoaderPlugin({
dir: "parse_as_js",
files: {
"index.ts": `import answer from "./answer.txt";\nconsole.log(answer);\n`,
// The extension's `text` loader would export this string verbatim.
"answer.txt": "export default 40 + 2;",
},
entrypoint: "index.ts",
filter: /\.txt$/,
symbol: "plugin_impl_parse_as_js",
});

expect(await run(result.outputs[0].path)).toBe("42\n");
});

it("honors a non-JavaScript loader set by the plugin", async () => {
const result = await buildWithLoaderPlugin({
dir: "parse_as_json",
files: {
"index.ts": `import data from "./data.txt";\nconsole.log(JSON.stringify(data), typeof data);\n`,
"data.txt": `{ "answer": 42 }`,
},
entrypoint: "index.ts",
filter: /\.txt$/,
symbol: "plugin_impl_parse_as_json",
});

expect(await run(result.outputs[0].path)).toBe('{"answer":42} object\n');
});

it("keeps the side effects of a file the plugin moved off the file loader", async () => {
const result = await buildWithLoaderPlugin({
dir: "file_to_js_side_effects",
files: {
"index.ts": `import "./effect.foo";\nconsole.log("index");\n`,
// .foo defaults to the `file` loader, and a bare import of a `file`
// loader module is dropped as side-effect free.
"effect.foo": `console.log("effect");`,
},
entrypoint: "index.ts",
filter: /\.foo$/,
symbol: "plugin_impl_parse_as_js",
});

expect(await run(result.outputs[0].path)).toBe("effect\nindex\n");
});

it("copies the file as an asset when the plugin switches it to the file loader", async () => {
const result = await buildWithLoaderPlugin({
dir: "js_to_file",
files: {
"index.ts": `import url from "./thing.js";\nconsole.log(url);\n`,
"thing.js": `export default "bundled as js";`,
},
entrypoint: "index.ts",
// Nothing else in this build uses the file loader, so the asset only
// gets written if the plugin's choice is accounted for.
filter: /thing\.js$/,
symbol: "plugin_impl_parse_as_file",
});

expect(result.outputs.map(output => output.kind)).toEqual(["entry-point", "asset"]);
const [entry, asset] = result.outputs;
expect(await asset.text()).toBe(`export default "bundled as js";`);
expect(await run(entry.path)).toBe(`./${path.basename(asset.path)}\n`);
});
});

type AdditionalFile = {
name: string;
contents: BunFile | string;
Expand Down
73 changes: 73 additions & 0 deletions test/bundler/native_plugin.cc
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,79 @@ plugin_impl_baz(const OnBeforeParseArguments *args,
plugin_impl_with_needle(args, result, "baz");
}

/*
Replaces the source with a JS module that default-exports the original
contents as a string literal, and tells Bun to parse the replacement with the
JS loader no matter what the file extension's loader is (the same shape as a
plugin compiling e.g. .mdx into JS).

The original contents are spliced into a double-quoted literal verbatim, so
the tests only feed it contents without quotes, backslashes or newlines.
*/
extern "C" BUN_PLUGIN_EXPORT void
plugin_impl_compile_to_js(const OnBeforeParseArguments *args,
OnBeforeParseResult *result) {
if (result->fetchSourceCode(args, result) != 0) {
exit(1);
}

static const char prefix[] = "export default \"";
static const char suffix[] = "\";";
const size_t prefix_len = sizeof(prefix) - 1;
const size_t suffix_len = sizeof(suffix) - 1;

size_t new_len = prefix_len + result->source_len + suffix_len;
char *new_source = (char *)malloc(new_len);
if (new_source == nullptr) {
exit(1);
}
memcpy(new_source, prefix, prefix_len);
memcpy(new_source + prefix_len, result->source_ptr, result->source_len);
memcpy(new_source + prefix_len + result->source_len, suffix, suffix_len);

std::atomic<size_t> *free_counter = nullptr;
if (args->external) {
free_counter = &((External *)args->external)->compilation_ctx_freed_count;
}

result->source_ptr = (uint8_t *)new_source;
result->source_len = new_len;
result->loader = BUN_LOADER_JS;
result->plugin_source_code_context =
compilation_ctx_new(new_source, new_len, free_counter);
result->free_plugin_source_code_context =
(void (*)(void *))compilation_ctx_free;
}

/*
Leaves the source untouched and only overrides the loader Bun parses it with.
*/
static void parse_with_loader(const OnBeforeParseArguments *args,
OnBeforeParseResult *result, BunLoader loader) {
if (result->fetchSourceCode(args, result) != 0) {
exit(1);
}
result->loader = loader;
}

extern "C" BUN_PLUGIN_EXPORT void
plugin_impl_parse_as_js(const OnBeforeParseArguments *args,
OnBeforeParseResult *result) {
parse_with_loader(args, result, BUN_LOADER_JS);
}

extern "C" BUN_PLUGIN_EXPORT void
plugin_impl_parse_as_json(const OnBeforeParseArguments *args,
OnBeforeParseResult *result) {
parse_with_loader(args, result, BUN_LOADER_JSON);
}

extern "C" BUN_PLUGIN_EXPORT void
plugin_impl_parse_as_file(const OnBeforeParseArguments *args,
OnBeforeParseResult *result) {
parse_with_loader(args, result, BUN_LOADER_FILE);
}

extern "C" void finalizer(napi_env env, void *data, void *hint) {
External *external = (External *)data;
if (external != nullptr) {
Expand Down