Skip to content

Commit

Permalink
Add support for mix build groups
Browse files Browse the repository at this point in the history
  • Loading branch information
thecrypticace committed Sep 28, 2021
1 parent bde5ed2 commit 97f55ff
Show file tree
Hide file tree
Showing 26 changed files with 713 additions and 125 deletions.
150 changes: 150 additions & 0 deletions docs/build-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Build Groups

Build groups are separate named configurations inside a single webpack.mix.js file. A build group can define it's own assets, behavior, plugins, webpack config, etc.

## Defining build groups

Defining a build group requires calling `mix.group` with two arguments: a name and a callback. Providing a name allows you to tell Mix to only build a specific group (or set of groups). Build group names are _case insensitive_. It is an error to define the same group multiple times. You may use any name you like for a group including spaces but we recommend slug-like names separated by hyphens or underscores.

The example below defines two separate builds for `app1` and `app2` that can be built independent of one another.

```js
mix.group("app1", (mix) => {
mix.js("src/app1/js/index.js", "dist/app1.js")
mix.css("src/app1/css/index.css", "dist/app1.css")
});

mix.group("app2", (mix) => {
mix.js("src/app2/js/index.js", "dist/app2.js")
mix.css("src/app2/css/index.css", "dist/app2.css")
mix.options({
processCssUrls: false,
})
});
```

Note: When using build groups do **not** call anything other than `mix.options`, `mix.group`, `mix.webpackConfig`, or `mix.extend` at the top level.

## Building specific groups

By default Mix will build all groups in the mix config file. However, if you are only working on a specific section of your app you may want to limit which groups you are actively buildomg. You can do this with the `--group` flag to the Mix CLI:

```shell
mix --group=.* # The default: Build all groups.

mix --group=app1 # Build only app1
mix --group=app1,app2 # Build app1 AND app2
mix --group="/app.+/" # Build all groups matching the regex /app.*/i
```

TODO: Do we want this to be a glob instead? A regex is more powerful but likely unncessary.

Note: We make no guarantee to the order of builds nor about whether or not they build in parallel. This may change with a future release.

## Consistent Asset Versioning

With the advent of build groups we've introduced a new implementation of versioning for assets. All assets are versioned directly by webpack and as such the hashes of these assets are idential to the ones generated by webpack which should get rid of any chunk hash differences! However, this new system is fundamentally incompatible with the old system. Because of this we will only switch to it when you opt in to using build groups.

## Automatic Manifest Merging

When using build groups Mix switches to a new manifest generation system. Now the manifest is generated during the webpack build process instead of after completion. In addition to this we now automatically merge the contents of the manifest on disk with the generated manifest.

TODO: What do we do about stale manifest resources? Do we ignore the manifest on disk if the user starts a build for all groups?
TODO: We won't support concurrent builds YET but with a new compilation runtime we'll end up running webpack ourselves. We'll have to use locking on the generation state such that the manifest update only runs once during the _processAssets + reporting_ stage.

## Shared Mix Option Customizations

A set of default options is created when mix starts. All build groups inherit these options and can override any of them on a per-group basis by calling `mix.options()` inside the call to `mix.group()`. Since it is likely that multiple groups will share common configuration you can call `mix.options()` at the top-level and these changes will be merged into each group. Options specified inside a group take precedence over any defined at a top level.

```js
mix.options({ processCssUrls: false })

mix.group("client", (mix) => {
mix.js("src/js/index.js", "dist/client.js")
});

mix.group("server", (mix) => {
mix.js("src/js/index.js", "dist/server.js")
mix.options({
legacyNodePolyfills: false,
})
});
```

In this example there are two groups:
- Both have css url processing turned off
- Only the "server" build has legacy node polyfills turned off (as the default is currently `true`)

## Shared Webpack Config Customizations

Just like for mix options it can be common for all groups in a build to inherit common webpack config customizations. We also allow calls to `mix.webpackConfig()` and `mix.override()` at a top-level to handle common customizations of the webpack config across all groups. Callbacks provided to top-level `mix.webpackConfig` and `mix.override` will be called once per group.

When processing every group we go through the following steps:
1. Start with a default webpack config
2. Merge config for top-level calls to `mix.webpackConfig`
3. Merge config for the group-level calls to `mix.webpackConfig`
4. Merge config for top-level calls to `mix.override`
5. Merge config for the group-level calls to `mix.override`

In this scenario a top-level call to mix.override can override per-group options.

TODO: Check this order as `mix.webpackConfig` calls override internally
TODO: Is this okay? Should it be top-level and then group-level in order instead?
TODO: I could see a scenario where providing the current group name to a top-level call is useful. We could provide it to all calls for consistency but it'd be technically unncessary.

```js
mix.options({ processCssUrls: false })

mix.group("client", (mix) => {
mix.js("src/js/index.js", "dist/client.js")

mix.override(() => {
return {
// TODO
}
})
});

mix.group("server", (mix) => {
mix.js("src/js/index.js", "dist/server.js")

mix.override(() => {
return {
// TODO
}
})
});

mix.override(() => {
return {
// Runs before mix.group's override
}
})
```

## Notes:

Handling components/extensions with build groups:

1. Components are created and instantiated a single time. Registered multiple times. OR;
2. Every build group re-creates & instantiates components. This means that the only shared state between groups (if any is needed) must be static or file-local vars.
This also means no communication between groups which may be a good idea.

Functional components will work this way regardless.

3. Components are passed the `mix` object which switches what api, config, etc all point to. The relevant pieces are stored in a BuildContext object and they're swapped at definition time and build time.
Ideally the swapping only needs to happen once though because of the way the object graphs are tied together.


4. Either calling mix.extension in a group adds to the top-level / global API or it's an error. Which is it?

# TODO (about build dependencies):

Create two special webpack builds that happen after all other builds
1. Handles copying assets
2. Handles manifest generation

Use configuration names & dependencies to make these run after all other builds are complete.
If we're going to support parallel builds in Mix v7 then we'll have to build this graph ourself.
However, as long as there are not nested build groups (the group would just get pushed to the top-level)
then the graph should only ever be two nodes deep.
5 changes: 4 additions & 1 deletion setup/webpack.config.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import Mix from '../src/Mix';

export default async function () {
const mix = Mix.primary;
const mix = Mix.shared;

// Load the user's mix config
await mix.load();

// Prepare any matching build groups
await mix.setup();

// Install any missing dependencies
await mix.installDependencies();

Expand Down
71 changes: 71 additions & 0 deletions src/Build/BuildContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const { Chunks } = require('../Chunks');
const Task = require('../tasks/Task');
const { Manifest } = require('./Manifest');

/**
* Holds all the data necessary for the current build
*/
exports.BuildContext = class BuildContext {
/**
* @param {import('../Mix')} mix
*/
constructor(mix) {
/** @internal */
this.mix = mix;

/**
* @public
* @type {typeof mix.config}
*/
this.config = Object.create(mix.config);

/**
* @public
*/
this.chunks = new Chunks(mix);

/**
* @public
*/
this.manifest = new Manifest();

/**
* @type {Task[]}
* @internal
**/
this.tasks = [];

/** Record<string, any> */
this.metadata = {};

// TODO: Do we want an event dispatcher here?
// How would we implement mix.before on a per-group basis
// Maybe it is only meant to be top-level?
}

/**
* Queue up a new task.
* TODO: Add a "stage" to tasks so they can run at different points during the build
*
* @param {Task} task
* @param {{ when: "before" | "during" | "after"}} options
*/
addTask(task, options) {
this.tasks.push(task);
}

/**
* @returns {import("../../types/index")}
*/
get api() {
if (!this._api) {
this._api = this.mix.registrar.installAll();

// @ts-ignore
this._api.inProduction = () => this.config.production;
}

// @ts-ignore
return this._api;
}
};
89 changes: 89 additions & 0 deletions src/Build/BuildGroup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const WebpackConfig = require('../builder/WebpackConfig');
const { BuildContext } = require('./BuildContext');

/**
* @typedef {(api: BuildContext['api'], context: BuildContext) => void|Promise<void>} GroupCallback
*/

/**
* Manages build groups
*/
exports.BuildGroup = class BuildGroup {
/**
* @param {object} param0
* @param {string} param0.name
* @param {GroupCallback} param0.callback
* @param {import('../Mix')} param0.mix
* @internal
*/
constructor({ name, mix, callback }) {
this.name = name;
this.mix = mix;
this.callback = callback;
this.context = new BuildContext(mix);
}

/**
* @internal
*
* For parallel build mode if we get to it. Probably won't.
*/
async build() {
const webpack = require('webpack');

return await webpack(await this.config());
}

/**
* Build the webpack configs for this context and all of its children
*
* @internal
*/
async config() {
// TODO: We should run setup as early as possible
// Maybe in Mix.init?
await this.setup();

return new WebpackConfig(this.mix).build();
}

/**
* @internal
*/
async setup() {
return this.whileCurrent(() => this.callback(this.context.api, this.context));
}

/**
* @template T
* @param {() => T} callback
* @returns {Promise<T>}
*/
async whileCurrent(callback) {
this.mix.pushCurrent(this);

try {
return await callback();
} finally {
this.mix.popCurrent();
}
}

get shouldBeBuilt() {
// TODO: Support simple wildcards? like foo_* and support regex when using slashes /foo_.*/
const pattern = new RegExp(`^${process.env.MIX_GROUP || '.*'}$`, 'i');

return !!this.name.match(pattern);
}

/**
* @internal
* @deprecated
*/
makeCurrent() {
global.Config = this.context.config;
this.context.chunks.makeCurrent();

return this;
}
};
33 changes: 33 additions & 0 deletions src/Build/Manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const fs = require('fs/promises');

exports.Manifest = class Manifest {
/** @param {Record<string, string>} records */
constructor(records = {}) {
this.records = records;
}

/**
* @param {string} filepath
*/
static async read(filepath) {
const content = await fs.readFile(filepath, { encoding: 'utf-8' });
const records = JSON.parse(content);

// TODO: Verify that the json is Record<string, string>

return new Manifest(records);
}

get() {
return Object.fromEntries(
Object.entries(this.records).sort((a, b) => a[0].localeCompare(b[0]))
);
}

/**
* @param {string} filepath
*/
async write(filepath) {
await fs.writeFile(filepath, JSON.stringify(this.get()));
}
};
1 change: 1 addition & 0 deletions src/Chunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Chunks {
this.runtime = false;
}

/** @deprecated */
makeCurrent() {
Chunks._instance = this;
}
Expand Down

0 comments on commit 97f55ff

Please sign in to comment.