diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index c955400bbf8..2d8be8485be 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -9,6 +9,9 @@ type TsdownConfigEntry = { entry?: Record | 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"); + }); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d7973cca81..70842b681fe 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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(); + 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`; + }, + }; + }, }; }