Files
bun.sh/test/bundler/native-plugin.test.ts

692 lines
23 KiB
TypeScript

import { BunFile, Loader } from "bun";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { bunEnv, bunExe, makeTree, tempDirWithFiles } from "harness";
import path from "path";
import bundlerPluginHeader from "../../packages/bun-native-bundler-plugin-api/bundler_plugin.h" with { type: "file" };
import source from "./native_plugin.cc" with { type: "file" };
import notAPlugin from "./not_native_plugin.cc" with { type: "file" };
describe("native-plugins", async () => {
const cwd = process.cwd();
let tempdir: string = "";
let outdir: string = "";
beforeAll(async () => {
const files = {
"bun-native-bundler-plugin-api/bundler_plugin.h": await Bun.file(bundlerPluginHeader).text(),
"plugin.cc": await Bun.file(source).text(),
"not_a_plugin.cc": await Bun.file(notAPlugin).text(),
"package.json": JSON.stringify({
"name": "fake-plugin",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.0.0",
},
"scripts": {
"build:napi": "node-gyp configure && node-gyp build",
},
"dependencies": {
"node-gyp": "10.2.0",
},
}),
"index.ts": /* ts */ `import values from "./stuff.ts";
import json from "./lmao.json";
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
const many_bar = ["bar","bar","bar","bar","bar","bar","bar"]
const many_baz = ["baz","baz","baz","baz","baz","baz","baz"]
console.log(JSON.stringify(json));
values;`,
"stuff.ts": `export default { foo: "bar", baz: "baz" }`,
"lmao.json": ``,
"binding.gyp": /* gyp */ `{
"targets": [
{
"target_name": "xXx123_foo_counter_321xXx",
"sources": [ "plugin.cc" ],
"include_dirs": [ "." ]
},
{
"target_name": "not_a_plugin",
"sources": [ "not_a_plugin.cc" ],
"include_dirs": [ "." ]
}
]
}`,
};
tempdir = tempDirWithFiles("native-plugins", files);
await makeTree(tempdir, files);
outdir = path.join(tempdir, "dist");
console.log("tempdir", tempdir);
process.chdir(tempdir);
await Bun.$`${bunExe()} i && ${bunExe()} build:napi`.env(bunEnv).cwd(tempdir);
});
beforeEach(() => {
const tempdir2 = tempDirWithFiles("native-plugins", {});
process.chdir(tempdir2);
});
afterEach(async () => {
await Bun.$`rm -rf ${outdir}`;
process.chdir(cwd);
});
it("works in a basic case", async () => {
await Bun.$`${bunExe()} i && ${bunExe()} build:napi`.env(bunEnv).cwd(tempdir);
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
const result = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
const chainedThis = build.onBeforeParse(
{ filter: /\.ts/ },
{ napiModule, symbol: "plugin_impl", external },
);
expect(chainedThis).toBe(build);
build.onLoad({ filter: /lmao\.json/ }, async ({ defer }) => {
await defer();
const count = napiModule.getFooCount(external);
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
if (!result.success) console.log(result);
expect(result.success).toBeTrue();
const output = await Bun.$`${bunExe()} run dist/index.js`.cwd(tempdir).json();
expect(output).toStrictEqual({ fooCount: 9 });
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(2);
});
it("doesn't explode when there are a lot of concurrent files", async () => {
// Generate 100 json files
const files: [filepath: string, var_name: string][] = await Promise.all(
Array.from({ length: 100 }, async (_, i) => {
await Bun.write(path.join(tempdir, "json_files", `lmao${i}.json`), `{}`);
return [`import json${i} from "./json_files/lmao${i}.json"`, `json${i}`];
}),
);
// Append the imports to index.ts
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
await Bun.$`echo ${files.map(([fp]) => fp).join("\n")} >> index.ts`;
await Bun.$`echo ${files.map(([, varname]) => `console.log(JSON.stringify(${varname}))`).join("\n")} >> index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
const result = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
build.onBeforeParse({ filter: /\.ts/ }, { napiModule, symbol: "plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
const count = napiModule.getFooCount(external);
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
if (!result.success) console.log(result);
console.log(result);
expect(result.success).toBeTrue();
const output = await Bun.$`${bunExe()} run dist/index.js`.cwd(tempdir).text();
const outputJsons = output
.trim()
.split("\n")
.map(s => JSON.parse(s));
for (const json of outputJsons) {
expect(json).toStrictEqual({ fooCount: 9 });
}
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(2);
});
// We clone the RegExp object in the C++ code so this test ensures that there
// is no funny business regarding the filter regular expression and multiple
// threads
it("doesn't explode when there are a lot of concurrent files AND the filter regex is used on the JS thread", async () => {
const filter = /\.ts/;
// Generate 100 json files
const files: [filepath: string, var_name: string][] = await Promise.all(
Array.from({ length: 100 }, async (_, i) => {
await Bun.write(path.join(tempdir, "json_files", `lmao${i}.json`), `{}`);
return [`import json${i} from "./json_files/lmao${i}.json"`, `json${i}`];
}),
);
// Append the imports to index.ts
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
await Bun.$`echo ${files.map(([fp]) => fp).join("\n")} >> index.ts`;
await Bun.$`echo ${files.map(([, varname]) => `console.log(JSON.stringify(${varname}))`).join("\n")} >> index.ts`;
await Bun.$`echo '(() => values)();' >> index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
const resultPromise = Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
const count = napiModule.getFooCount(external);
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
// Now saturate this thread with uses of the filter regex to test that nothing bad happens
// when the JS thread and the bundler thread use regexes concurrently
let dummy = 0;
for (let i = 0; i < 10000; i++) {
// Match the filter regex on some dummy string
dummy += filter.test("foo") ? 1 : 0;
}
const result = await resultPromise;
if (!result.success) console.log(result);
expect(result.success).toBeTrue();
const output = await Bun.$`${bunExe()} run dist/index.js`.cwd(tempdir).text();
const outputJsons = output
.trim()
.split("\n")
.map(s => JSON.parse(s));
for (const json of outputJsons) {
expect(json).toStrictEqual({ fooCount: 9 });
}
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(2);
});
it("doesn't explode when passing invalid external", async () => {
const filter = /\.ts/;
// Generate 100 json files
const files: [filepath: string, var_name: string][] = await Promise.all(
Array.from({ length: 100 }, async (_, i) => {
await Bun.write(path.join(tempdir, "json_files", `lmao${i}.json`), `{}`);
return [`import json${i} from "./json_files/lmao${i}.json"`, `json${i}`];
}),
);
// Append the imports to index.ts
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
await Bun.$`echo ${files.map(([fp]) => fp).join("\n")} >> index.ts`;
await Bun.$`echo ${files.map(([, varname]) => `console.log(JSON.stringify(${varname}))`).join("\n")} >> index.ts`;
const result = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = undefined;
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let count = 0;
try {
count = napiModule.getFooCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
const output = await Bun.$`${bunExe()} run dist/index.js`.cwd(tempdir).text();
const outputJsons = output
.trim()
.split("\n")
.map(s => JSON.parse(s));
for (const json of outputJsons) {
expect(json).toStrictEqual({ fooCount: 0 });
}
});
it("works when logging an error", async () => {
const filter = /\.ts/;
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
try {
const resultPromise = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
napiModule.setThrowsErrors(external, true);
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let count = 0;
try {
count = napiModule.getFooCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
} catch (e) {
const err = e as AggregateError;
expect(err.errors[0].message).toContain("Throwing an error");
expect(err.errors[0].level).toBe("error");
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(0);
return;
}
expect.unreachable("Should have caught an error");
});
it("works with versioning", async () => {
const filter = /\.ts/;
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
try {
const resultPromise = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
build.onBeforeParse({ filter }, { napiModule, symbol: "incompatible_version_plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let count = 0;
try {
count = napiModule.getFooCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
} catch (e) {
const err = e as AggregateError;
expect(err.errors[0].message).toContain(
"This plugin is built for a newer version of Bun than the one currently running.",
);
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(0);
return;
}
expect.unreachable("Should have caught an error");
});
// don't know how to reliably test this on windows
it.skipIf(process.platform === "win32")("prints name when plugin crashes", async () => {
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
const build_code = /* ts */ `
import * as path from "path";
const tempdir = process.env.BUN_TEST_TEMP_DIR;
const filter = /\.ts/;
const resultPromise = await Bun.build({
outdir: "dist",
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
napiModule.setWillCrash(external, true);
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let count = 0;
try {
count = napiModule.getFooCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
console.log(resultPromise);
`;
await Bun.$`echo ${build_code} > build.ts`;
const { stdout, stderr } = await Bun.$`BUN_TEST_TEMP_DIR=${tempdir} ${bunExe()} run build.ts`.throws(false);
const errorString = stderr.toString();
expect(errorString).toContain('\x1b[31m\x1b[2m"native_plugin_test"\x1b[0m');
});
it("detects when plugin sets function pointer but does not user context pointer", async () => {
const filter = /\.ts/;
const prelude = /* ts */ `import values from "./stuff.ts"
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
`;
await Bun.$`echo ${prelude} > index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
try {
const resultPromise = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
build.onBeforeParse(
{ filter },
{ napiModule, symbol: "plugin_impl_bad_free_function_pointer", external },
);
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let count = 0;
try {
count = napiModule.getFooCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount: count }),
loader: "json",
};
});
},
},
],
});
} catch (e) {
const err = e as AggregateError;
expect(err.errors[0].message).toContain(
"Native plugin set the `free_plugin_source_code_context` field without setting the `plugin_source_code_context` field.",
);
expect(err.errors[0].level).toBe("error");
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(0);
return;
}
expect.unreachable("Should have caught an error");
});
it("should fail gracefully when passing something that is NOT a bunler plugin", async () => {
const not_plugins = [require(path.join(tempdir, "build/Release/not_a_plugin.node")), 420, "hi", {}];
for (const napiModule of not_plugins) {
try {
await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "not_a_plugin",
setup(build) {
build.onBeforeParse({ filter: /\.ts/ }, { napiModule, symbol: "plugin_impl" });
},
},
],
});
expect.unreachable();
} catch (e) {
expect(e.toString()).toContain(
"onBeforeParse `napiModule` must be a Napi module which exports the `BUN_PLUGIN_NAME` symbol.",
);
}
}
});
it("should fail gracefully when can't find the symbol", async () => {
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
try {
await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "not_a_plugin",
setup(build) {
build.onBeforeParse({ filter: /\.ts/ }, { napiModule, symbol: "OOGA_BOOGA_420" });
},
},
],
});
expect.unreachable();
} catch (e) {
expect(e.toString()).toContain(
'TypeError [ERR_INVALID_ARG_TYPE]: Could not find the symbol "OOGA_BOOGA_420" in the given napi module.',
);
}
});
it("should use result of the first plugin that runs and doesn't execute the others", async () => {
const filter = /\.ts/;
const prelude = /* ts */ `import values from "./stuff.ts"
import json from "./lmao.json";
const many_foo = ["foo","foo","foo","foo","foo","foo","foo"]
const many_bar = ["bar","bar","bar","bar","bar","bar","bar"]
const many_baz = ["baz","baz","baz","baz","baz","baz","baz"]
console.log(JSON.stringify(json))
`;
await Bun.$`echo ${prelude} > index.ts`;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
const external = napiModule.createExternal();
const resultPromise = Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "xXx123_foo_counter_321xXx",
setup(build) {
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl", external });
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl_bar", external });
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl_baz", external });
build.onLoad({ filter: /\.json/ }, async ({ defer, path }) => {
await defer();
let fooCount = 0;
let barCount = 0;
let bazCount = 0;
try {
fooCount = napiModule.getFooCount(external);
barCount = napiModule.getBarCount(external);
bazCount = napiModule.getBazCount(external);
} catch (e) {}
return {
contents: JSON.stringify({ fooCount, barCount, bazCount }),
loader: "json",
};
});
},
},
],
});
const result = await resultPromise;
if (result.success) console.log(result);
expect(result.success).toBeTrue();
const output = await Bun.$`${bunExe()} run dist/index.js`.cwd(tempdir).json();
expect(output).toStrictEqual({ fooCount: 9, barCount: 0, bazCount: 0 });
const compilationCtxFreedCount = await napiModule.getCompilationCtxFreedCount(external);
expect(compilationCtxFreedCount).toBe(2);
});
type AdditionalFile = {
name: string;
contents: BunFile | string;
loader: Loader;
};
const additional_files: AdditionalFile[] = [
{
name: "bun.png",
contents: await Bun.file(path.join(import.meta.dir, "../integration/sharp/bun.png")),
loader: "file",
},
{
name: "index.js",
contents: /* ts */ `console.log('HELLO FRIENDS')`,
loader: "js",
},
{
name: "index.ts",
contents: /* ts */ `console.log('HELLO FRIENDS')`,
loader: "ts",
},
{
name: "lmao.jsx",
contents: /* ts */ `console.log('HELLO FRIENDS')`,
loader: "jsx",
},
{
name: "lmao.tsx",
contents: /* ts */ `console.log('HELLO FRIENDS')`,
loader: "tsx",
},
{
name: "lmao.toml",
contents: /* toml */ `foo = "bar"`,
loader: "toml",
},
{
name: "lmao.text",
contents: "HELLO FRIENDS",
loader: "text",
},
];
for (const { name, contents, loader } of additional_files) {
it(`works with ${loader} loader`, async () => {
await Bun.$`echo ${contents} > ${name}`;
const source = /* ts */ `import foo from "./${name}";
console.log(foo);`;
await Bun.$`echo ${source} > index.ts`;
const result = await Bun.build({
outdir,
entrypoints: [path.join(tempdir, "index.ts")],
plugins: [
{
name: "test",
setup(build) {
const ext = name.split(".").pop()!;
const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node"));
// Construct regexp to match the file extension
const filter = new RegExp(`\\.${ext}$`);
build.onBeforeParse({ filter }, { napiModule, symbol: "plugin_impl" });
},
},
],
});
expect(result.success).toBeTrue();
});
}
});