Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/xref-compose-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Compose every synthesized bundler config through one shared layering (`profile` → `tools.rsbuild` → `tools.rspack` → framework invariants), so a `tools` escape-hatch value reaches the MCP Apps Rsbuild config exactly as it reaches artifact scripts, hooks, MCP entries, the routed CLI bin, and the package build, and `output.cleanDistPath` stays off on every path. No artifact or `inspect --bundler` output changes. (#495)
51 changes: 51 additions & 0 deletions packages/agent-bundle/src/build/compose-layers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { AgentBundleToolsConfig } from '../core/types.ts';

/** The `tools.rspack` hatch shape: an Rspack config fragment, a mutator, or an array of either. */
export type ToolsRspackHatch = NonNullable<AgentBundleToolsConfig['rspack']>;

/** The `tools.rsbuild` hatch shape: an Rsbuild environment-config fragment. */
export type ToolsRsbuildFragment = NonNullable<AgentBundleToolsConfig['rsbuild']>;

/**
* The composition order every bundler config agent-bundle synthesizes
* follows, whichever engine lowers it: the framework profile first, the
* consumer `tools.rsbuild` fragment over it, the consumer `tools.rspack`
* hatch over that, and the framework invariant layer last, where no hatch
* value can reach it. Rslib's "raw user config highest" priority and
* Rspress's `builderConfig` position place the consumer exactly here.
*
* The layers are returned rather than merged because Rslib (`lib: [{ id }]`
* entries merged by `mergeRslibConfig`) and Rsbuild (`mergeRsbuildConfig`)
* take different containers; each caller lifts every layer into its own
* container and hands them to its engine's merge in this order. `lift` maps
* the two hatch fragments into the caller's layer type — the only place the
* workspace `@rsbuild/core` hatch types cross into the executing engine's.
*/
export const composeToolsLayers = <Layer>(options: {
readonly invariants: Layer;
readonly lift: {
readonly rsbuild: (fragment: ToolsRsbuildFragment) => Layer;
readonly rspack: (hatch: ToolsRspackHatch) => Layer;
};
readonly profile: Layer;
readonly tools?: AgentBundleToolsConfig;
}): readonly Layer[] => Object.freeze([
options.profile,
...(options.tools?.rsbuild === undefined ? [] : [options.lift.rsbuild(options.tools.rsbuild)]),
...(options.tools?.rspack === undefined ? [] : [options.lift.rspack(options.tools.rspack)]),
options.invariants,
]);

/**
* The invariant layer shared by every synthesized config. Dist cleaning
* stays off no matter what the consumer asks for: every surface of one
* target builds into one shared staged root, so an environment cleaning its
* dist path would delete sibling outputs already emitted there (the artifact
* is published atomically from the staged root instead). `enforce` is the
* engine-specific Rspack mutator appended after the consumer's, so a hatch
* mutator can neither strip the generated modules nor undo the invariants.
*/
export const frameworkInvariantLayer = <Mutator>(enforce: Mutator): {
readonly output: { readonly cleanDistPath: false };
readonly tools: { readonly rspack: Mutator };
} => ({ output: { cleanDistPath: false }, tools: { rspack: enforce } });
26 changes: 14 additions & 12 deletions packages/agent-bundle/src/build/mcp-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { extname, resolve } from 'node:path';
import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts';
import { stableJson } from '../core/digest.ts';
import type { AgentBundleMeta } from '../meta.ts';
import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts';
import { listArtifactFiles, resolveArtifactDestination } from './emit.ts';
import {
generatedMetaModulePath,
Expand Down Expand Up @@ -171,10 +172,10 @@ export const planCompiledMcpApps = (
/**
* One Rsbuild instance with one environment per app compiles every view in
* a single parallel run instead of a sequential per-app build loop. The
* consumer escape hatch merges over this synthesized profile with the
* framework invariant hook appended last; the resolved-config assertions in
* `compileMcpApps` bound what the hatch may change. `inspect --bundler`
* surfaces exactly this composition.
* consumer escape hatch merges over this synthesized profile in the shared
* `composeToolsLayers` order, framework invariant hook last; the
* resolved-config assertions in `compileMcpApps` bound what the hatch may
* change. `inspect --bundler` surfaces exactly this composition.
*/
export const composeMcpAppsRsbuildConfig = (
sources: readonly Pick<NormalizedMcpApp, 'name' | 'source' | 'template'>[],
Expand Down Expand Up @@ -228,15 +229,16 @@ export const composeMcpAppsRsbuildConfig = (
];
return config;
};
return mergeRsbuildConfig<RsbuildConfig>(
// The hatch types are this engine's own, so the layers lift unchanged.
return mergeRsbuildConfig<RsbuildConfig>(...composeToolsLayers<RsbuildConfig>({
invariants: frameworkInvariantLayer(enforceInvariants),
lift: {
rsbuild: (fragment) => fragment,
rspack: (hatch) => ({ tools: { rspack: hatch } }),
},
profile,
...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild]),
...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } }]),
// Merged last so the hatch cannot reach either invariant: dist cleaning
// would delete sibling outputs already emitted into the shared staged
// target root, so it stays off no matter what the consumer asks for.
{ output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } },
);
...(options.tools === undefined ? {} : { tools: options.tools }),
}));
};

export const compileMcpApps = async (
Expand Down
39 changes: 18 additions & 21 deletions packages/agent-bundle/src/build/rslib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isErrno } from '../core/errors.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleToolsConfig } from '../core/types.ts';
import type { AgentBundleMeta } from '../meta.ts';
import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts';
import { mcpEntryRuntimeSpecifier } from './entry-shell.ts';
import {
generatedMetaModulePath,
Expand Down Expand Up @@ -414,13 +415,12 @@ const assertExecutableConfig = (
};

/**
* Composes the full Rslib lib config for one synthesized entry: the
* framework profile, the consumer `tools` escape hatch merged over it
* (Rslib's "raw user config highest" priority), and the invariant enforcer
* hook appended last, all composed with Rslib's own `mergeRslibConfig`
* keyed by the synthesized lib id. `buildWithRslib` lowers exactly this
* composition and `inspect --bundler` surfaces it, so the two can never
* drift.
* Composes the full Rslib lib config for one synthesized entry in the
* shared `composeToolsLayers` order — the framework profile, the consumer
* `tools` escape hatch over it, the invariant enforcer hook last — with
* Rslib's own `mergeRslibConfig` keyed by the synthesized lib id.
* `buildWithRslib` lowers exactly this composition and `inspect --bundler`
* surfaces it, so the two can never drift.
*/
export const composeEntryLibConfig = (
entry: RslibEntry,
Expand Down Expand Up @@ -567,20 +567,17 @@ export const composeEntryLibConfig = (
...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }),
},
};
const merged = mergeRslibConfig(
{ lib: [profile] },
options.tools?.rsbuild === undefined
? undefined
: { lib: [{ ...asRslibEnvironmentFragment(options.tools.rsbuild), id: libId }] },
options.tools?.rspack === undefined
? undefined
: { lib: [{ id: libId, tools: { rspack: asRslibRspackHatch(options.tools.rspack) } }] },
// Merged last so the hatch cannot reach either invariant. Dist cleaning
// would delete sibling entries already emitted into the shared staged
// root, so it stays off no matter what the consumer asks for; the
// emitted output is published atomically from a staged root instead.
{ lib: [{ id: libId, output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }] },
);
// Every layer is keyed by the synthesized lib id so `mergeRslibConfig`
// folds them into one lib entry in `composeToolsLayers` order.
const merged = mergeRslibConfig(...composeToolsLayers<RslibLibConfig>({
invariants: { id: libId, ...frameworkInvariantLayer(enforceInvariants) },
lift: {
rsbuild: (fragment) => ({ ...asRslibEnvironmentFragment(fragment), id: libId }),
rspack: (hatch) => ({ id: libId, tools: { rspack: asRslibRspackHatch(hatch) } }),
},
profile,
...(options.tools === undefined ? {} : { tools: options.tools }),
}).map((lib) => ({ lib: [lib] })));
const lib = merged.lib?.[0];
if (merged.lib?.length !== 1 || lib === undefined) {
throw new Error(`Rslib config composition did not merge one lib entry for ${JSON.stringify(libId)}.`);
Expand Down
101 changes: 101 additions & 0 deletions packages/agent-bundle/tests/compose-layers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from '@rstest/core';

import { composeToolsLayers, frameworkInvariantLayer } from '../src/build/compose-layers.ts';
import { composeMcpAppsRsbuildConfig } from '../src/build/mcp-apps.ts';
import { composeEntryLibConfig, type RslibEntry } from '../src/build/rslib.ts';
import type { AgentBundleToolsConfig } from '../src/core/types.ts';
import type { AgentBundleMeta } from '../src/meta.ts';

const meta: AgentBundleMeta = Object.freeze({
name: 'layers-fixture',
packageName: undefined,
packageVersion: undefined,
version: '1.0.0',
});

const entry: RslibEntry = Object.freeze({
name: 'tool',
outputRelativePath: 'scripts/tool.mjs',
source: '/project/src/tool.ts',
sourceInputs: ['/project/src/tool.ts'],
});

const app = Object.freeze({ name: 'dashboard', source: '/project/src/apps/dashboard.tsx', template: undefined });

/**
* A hatch touching every layer: `tools.rsbuild` sets a profile-owned output
* option (reaches the resolved config), asks to clean the dist path (the
* invariant must win), and `tools.rspack` contributes both a config fragment
* and a mutator (must run before the framework invariant mutator).
*/
const rspackFragment = Object.freeze({ resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } });
const rspackMutator = (): void => undefined;
const tools: AgentBundleToolsConfig = {
rsbuild: { output: { cleanDistPath: true, legalComments: 'linked' } },
rspack: [rspackFragment, rspackMutator],
};

const invariantMutatorOf = (config: { readonly tools?: { readonly rspack?: unknown } }): readonly unknown[] => {
const rspack = config.tools?.rspack;
return Array.isArray(rspack) ? rspack : [rspack];
};

describe('composeToolsLayers', () => {
it('orders profile, tools.rsbuild, tools.rspack, invariants and omits absent hatch layers', () => {
const lift = { rsbuild: (fragment: unknown) => ({ layer: 'rsbuild', fragment }), rspack: (hatch: unknown) => ({ layer: 'rspack', hatch }) };
expect(composeToolsLayers<unknown>({ invariants: 'invariants', lift, profile: 'profile', tools })).toEqual([
'profile',
{ layer: 'rsbuild', fragment: tools.rsbuild },
{ layer: 'rspack', hatch: tools.rspack },
'invariants',
]);
expect(composeToolsLayers<unknown>({ invariants: 'invariants', lift, profile: 'profile' })).toEqual(['profile', 'invariants']);
expect(composeToolsLayers<unknown>({ invariants: 'invariants', lift, profile: 'profile', tools: { rspack: rspackMutator } }))
.toEqual(['profile', { layer: 'rspack', hatch: rspackMutator }, 'invariants']);
});

it('pins the invariant layer to cleanDistPath off plus the engine mutator', () => {
const enforce = (): void => undefined;
expect(frameworkInvariantLayer(enforce)).toEqual({ output: { cleanDistPath: false }, tools: { rspack: enforce } });
});
});

describe('the shared layering reaches every synthesized config the same way', () => {
const lib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable', tools });
const apps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable', tools });

it('lets a tools.rsbuild fragment reach the MCP Apps config exactly as it reaches an entry lib', () => {
expect(lib.output?.legalComments).toBe('linked');
expect(apps.output?.legalComments).toBe('linked');
// The profile beneath the fragment survives the merge on both paths.
expect(lib.output?.filename).toEqual({ js: 'scripts/tool.mjs' });
expect(apps.output?.filename).toEqual({ css: '[name].css', html: '[name].html', js: '[name].js' });
});

it('applies the tools.rspack hatch before the framework invariant mutator on both paths', () => {
for (const config of [lib, apps]) {
const mutators = invariantMutatorOf(config);
expect(mutators.slice(0, 2)).toEqual([rspackFragment, rspackMutator]);
expect(mutators).toHaveLength(3);
const enforce = mutators[2];
expect(typeof enforce).toBe('function');
expect((enforce as { readonly name: string }).name).toBe('enforceInvariants');
}
});

it('keeps the invariants above the hatch: cleanDistPath stays off on both paths', () => {
expect(lib.output?.cleanDistPath).toBe(false);
expect(apps.output?.cleanDistPath).toBe(false);
});

it('composes only the profile and the invariants without a hatch', () => {
const bareLib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable' });
const bareApps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable' });
for (const config of [bareLib, bareApps]) {
const mutators = invariantMutatorOf(config);
expect(mutators).toHaveLength(1);
expect((mutators[0] as { readonly name: string }).name).toBe('enforceInvariants');
expect(config.output?.cleanDistPath).toBe(false);
}
});
});
Loading