build: fix plugin shared chunk routing to preserve node_modules resolution

This commit is contained in:
郑苏波 (Super Zheng)
2026-04-03 15:33:57 +08:00
committed by George Zhang
parent 1e8564cb13
commit d5a06defda
2 changed files with 154 additions and 0 deletions

View File

@@ -9,6 +9,9 @@ type TsdownConfigEntry = {
entry?: Record<string, string> | string[];
inputOptions?: TsdownInputOptions;
outDir?: string;
outputOptions?: (options: unknown) => {
chunkFileNames?: (chunkInfo: { name: string; moduleIds: string[] }) => string;
};
};
type TsdownLog = {
@@ -145,4 +148,117 @@ describe("tsdown config", () => {
expect(handled).toEqual([log]);
});
it("routes bundled plugin shared chunks to their own directory", () => {
const configs = asConfigArray(tsdownConfig);
const unifiedGraph = configs.find((config) => entryKeys(config).includes("index"));
expect(unifiedGraph).toBeDefined();
// Extract the chunkFileNames function from outputOptions
const outputOptionsFn = unifiedGraph!.outputOptions;
expect(typeof outputOptionsFn).toBe("function");
const outputOptions = outputOptionsFn!({});
const chunkFileNames = outputOptions.chunkFileNames!;
expect(typeof chunkFileNames).toBe("function");
// Scenario 1: A chunk containing only slack files
expect(
chunkFileNames({
name: "shared-slack-api",
moduleIds: [
"extensions/slack/src/api.ts",
"extensions/slack/src/token.ts",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 2: A chunk containing only telegram files
expect(
chunkFileNames({
name: "shared-telegram-api",
moduleIds: [
"extensions/telegram/src/api.ts",
"extensions/telegram/src/config.ts",
],
}),
).toBe("extensions/telegram/[name]-[hash].js");
// Scenario 3: A chunk containing mixed files (architectural violation)
expect(
chunkFileNames({
name: "shared-mixed",
moduleIds: [
"extensions/slack/src/api.ts",
"extensions/telegram/src/api.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 4: A chunk containing only core files
expect(
chunkFileNames({
name: "shared-core",
moduleIds: [
"src/gateway/server-http.ts",
"src/gateway/client.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 5: A chunk containing plugin and core files
expect(
chunkFileNames({
name: "shared-plugin-and-core",
moduleIds: [
"extensions/slack/src/api.ts",
"src/gateway/server-http.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 5b: A chunk containing plugin files and virtual modules
expect(
chunkFileNames({
name: "shared-plugin-with-virtual",
moduleIds: [
"extensions/slack/src/api.ts",
"\0commonjsHelpers.js",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 5c: A chunk containing plugin files and node_modules dependencies
expect(
chunkFileNames({
name: "shared-plugin-with-deps",
moduleIds: [
"extensions/slack/src/api.ts",
"node_modules/@slack/web-api/index.js",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 6: Fallback to previous function
const outputOptionsWithFn = outputOptionsFn!({
chunkFileNames: () => "custom-fn-[hash].js",
});
expect(
outputOptionsWithFn.chunkFileNames!({
name: "shared-core",
moduleIds: ["src/gateway/server-http.ts"],
}),
).toBe("custom-fn-[hash].js");
// Scenario 7: Fallback to previous string
const outputOptionsWithStr = outputOptionsFn!({
chunkFileNames: "custom-str-[hash].js",
});
expect(
outputOptionsWithStr.chunkFileNames!({
name: "shared-core",
moduleIds: ["src/gateway/server-http.ts"],
}),
).toBe("custom-str-[hash].js");
});
});

View File

@@ -88,6 +88,44 @@ function nodeBuildConfig(config: UserConfig): UserConfig {
fixedExtension: false,
platform: "node",
inputOptions: buildInputOptions,
outputOptions(options) {
const previousChunkFileNames = options.chunkFileNames;
return {
...options,
chunkFileNames(chunkInfo) {
const moduleIds = chunkInfo.moduleIds || [];
const extensionIds = new Set<string>();
let hasNonPluginModules = false;
for (const id of moduleIds) {
if (id.startsWith("\0")) {
continue;
}
const absoluteId = path.resolve(process.cwd(), id);
const relativeToRoot = path.relative(process.cwd(), absoluteId);
const parts = relativeToRoot.split(path.sep);
if (parts[0] === "extensions" && parts.length > 2) {
extensionIds.add(parts[1]);
} else if (parts.includes("node_modules")) {
continue;
} else {
hasNonPluginModules = true;
}
}
if (extensionIds.size === 1 && !hasNonPluginModules) {
const extId = Array.from(extensionIds)[0];
return `extensions/${extId}/[name]-[hash].js`;
}
if (typeof previousChunkFileNames === "function") {
return previousChunkFileNames(chunkInfo);
}
if (typeof previousChunkFileNames === "string") {
return previousChunkFileNames;
}
return `[name]-[hash].js`;
},
};
},
};
}