Repository: parcel-bundler/parcel
Pull request #4788 (2020-06-22 06:19:04)

Optimize for HTTP/2 by default and support configuring bundler options
Depends on #4786.

This changes the default options for the bundler to optimize for HTTP/2. In particular, it raises the number of parallel requests allowed from 6 to 25, and lowers the minimum bundle size from 30kb to 20kb. This should allow more granular caching while keeping good performance for apps served with HTTP/2. This is based on the work the Chrome team did with the Next.js team on more granular chunking. See https://web.dev/granular-chunking-nextjs/.

Since HTTP/2 is very widely supported by all browsers and most CDNs/servers, we believe that this is a good default. However, if you are still using HTTP/1.1, or otherwise wish the change these thresholds, you can now configure the bundler. This is done through the package.json in the project root.

This will use the default options for HTTP/1.1:

```json
{
  "@parcel/bundler-default": {
    "http": 1
  }
}
```

If you'd like to override individual options, you can do so as well:

```json
{
  "@parcel/bundler-default": {
    "minBundles": 1,
    "minBundleSize": 0,
    "maxParallelRequests": 10
  }
}
```

I'm not 100% sure about this method of configuring vs others (e.g. `.bundlerrc` file, targets?). Let me know what you think!

height[bot]: > Link [Height](https://parcel.height.app) tasks by mentioning a task ID in the pull request title or description, commit messages, or comments.
>
> 💡Tip: You can also use "Close T-X" to automatically close a task when the pull request is merged.
<!-- Sent by Height -->
wbinnssmith: This is awesome 🤩 

I think the config needs to be captured in the BundleGraph's cache key somehow, yeah? cc @padmaia 
devongovett: @wbinnssmith yeah see todo comment. I think it's gonna depend on making the bundler a request.
padmaia: It will need to be part of the cache key, which wouldn't require making it a request, but it would best be done by supporting the `loadConfig` method in bundler plugins. Making bundling a request and adding invalidation will make sure the config file causes a rebuild.

Patch:
diff --git a/packages/bundlers/default/package.json b/packages/bundlers/default/package.json
index 5819fafd9..10afc406f 100644
--- a/packages/bundlers/default/package.json
+++ b/packages/bundlers/default/package.json
@@ -20,6 +20,7 @@
     "parcel": "^2.0.0-alpha.1.1"
   },
   "dependencies": {
+    "@parcel/diagnostic": "^2.0.0-beta.1",
     "@parcel/plugin": "2.0.0-beta.1",
     "@parcel/utils": "2.0.0-beta.1",
     "nullthrows": "^1.1.1"
diff --git a/packages/bundlers/default/src/DefaultBundler.js b/packages/bundlers/default/src/DefaultBundler.js
index 81d9ba1c8..a244515bf 100644
--- a/packages/bundlers/default/src/DefaultBundler.js
+++ b/packages/bundlers/default/src/DefaultBundler.js
@@ -5,17 +5,29 @@ import type {
   Bundle,
   BundleGroup,
   MutableBundleGraph,
+  PluginOptions,
 } from '@parcel/types';
+import type {SchemaEntity} from '@parcel/utils';
 
 import invariant from 'assert';
 import {Bundler} from '@parcel/plugin';
-import {md5FromString} from '@parcel/utils';
+import {loadConfig, md5FromString, validateSchema} from '@parcel/utils';
 import nullthrows from 'nullthrows';
-
-const OPTIONS = {
-  minBundles: 1,
-  minBundleSize: 30000,
-  maxParallelRequests: 6,
+import path from 'path';
+import {encodeJSONKeyComponent} from '@parcel/diagnostic';
+
+// Default options by http version.
+const HTTP_OPTIONS = {
+  '1': {
+    minBundles: 1,
+    minBundleSize: 30000,
+    maxParallelRequests: 6,
+  },
+  '2': {
+    minBundles: 1,
+    minBundleSize: 20000,
+    maxParallelRequests: 25,
+  },
 };
 
 export default new Bundler({
@@ -27,6 +39,10 @@ export default new Bundler({
   // 5. If the sub-graph from an asset is >= 30kb, and the number of parallel requests in the bundle group is < 5, create a new bundle containing the sub-graph.
   // 6. If two assets are always seen together, put them in the same extracted bundle
 
+  loadConfig({options}) {
+    return loadBundlerConfig(options);
+  },
+
   bundle({bundleGraph}) {
     let bundleRoots: Map<Bundle, Array<Asset>> = new Map();
     let bundlesByEntryAsset: Map<Asset, Bundle> = new Map();
@@ -202,7 +218,9 @@ export default new Bundler({
     }
   },
 
-  optimize({bundleGraph}) {
+  optimize({bundleGraph, config}) {
+    invariant(config != null);
+
     // Step 2: Remove asset graphs that begin with entries to other bundles.
     bundleGraph.traverseBundles(bundle => {
       if (bundle.isInline || !bundle.isSplittable) {
@@ -244,7 +262,7 @@ export default new Bundler({
             group =>
               bundleGraph
                 .getBundlesInBundleGroup(group)
-                .filter(b => !b.isInline).length < OPTIONS.maxParallelRequests,
+                .filter(b => !b.isInline).length < config.maxParallelRequests,
           )
         ) {
           bundleGraph.removeAssetGraphFromBundle(mainEntry, candidate);
@@ -292,7 +310,7 @@ export default new Bundler({
           );
         });
 
-      if (containingBundles.length > OPTIONS.minBundles) {
+      if (containingBundles.length > config.minBundles) {
         let id = containingBundles
           .map(b => b.id)
           .sort()
@@ -324,7 +342,7 @@ export default new Bundler({
       sourceBundles: Set<Bundle>,
       size: number,
     |}> = Array.from(candidateBundles.values())
-      .filter(bundle => bundle.size >= OPTIONS.minBundleSize)
+      .filter(bundle => bundle.size >= config.minBundleSize)
       .sort((a, b) => b.size - a.size);
 
     let sharedBundles = [];
@@ -345,7 +363,7 @@ export default new Bundler({
         Array.from(bundleGroups).every(
           group =>
             bundleGraph.getBundlesInBundleGroup(group).filter(b => !b.isInline)
-              .length >= OPTIONS.maxParallelRequests,
+              .length >= config.maxParallelRequests,
         )
       ) {
         continue;
@@ -377,8 +395,7 @@ export default new Bundler({
               bundleGroup =>
                 bundleGraph
                   .getBundlesInBundleGroup(bundleGroup)
-                  .filter(b => !b.isInline).length <
-                OPTIONS.maxParallelRequests,
+                  .filter(b => !b.isInline).length < config.maxParallelRequests,
             )
           ) {
             bundleGraph.removeAssetGraphFromBundle(asset, bundle);
@@ -392,7 +409,7 @@ export default new Bundler({
         if (
           bundleGraph
             .getBundlesInBundleGroup(bundleGroup)
-            .filter(b => !b.isInline).length < OPTIONS.maxParallelRequests
+            .filter(b => !b.isInline).length < config.maxParallelRequests
         ) {
           bundleGraph.addBundleToBundleGroup(sharedBundle, bundleGroup);
         }
@@ -475,3 +492,63 @@ function deduplicate(bundleGraph: MutableBundleGraph) {
     }
   });
 }
+
+const CONFIG_SCHEMA: SchemaEntity = {
+  type: 'object',
+  properties: {
+    http: {
+      type: 'number',
+      enum: Object.keys(HTTP_OPTIONS).map(k => Number(k)),
+    },
+    minBundles: {
+      type: 'number',
+    },
+    minBundleSize: {
+      type: 'number',
+    },
+    maxParallelRequests: {
+      type: 'number',
+    },
+  },
+};
+
+async function loadBundlerConfig(options: PluginOptions) {
+  let result = await loadConfig(
+    options.inputFS,
+    path.join(options.projectRoot, 'index'),
+    ['package.json'],
+  );
+
+  let config = result?.config['@parcel/bundler-default'];
+  if (!config) {
+    return {
+      config: HTTP_OPTIONS['2'],
+      files: result?.files ?? [],
+    };
+  }
+
+  invariant(result != null);
+
+  validateSchema.diagnostic(
+    CONFIG_SCHEMA,
+    config,
+    result.files[0].filePath,
+    result.config,
+    '@parcel/bundler-default',
+    `/${encodeJSONKeyComponent('@parcel/bundler-default')}`,
+    'Invalid config for @parcel/bundler-default',
+  );
+
+  let http = config.http ?? 2;
+  let defaults = HTTP_OPTIONS[http];
+
+  return {
+    config: {
+      minBundles: config.minBundles ?? defaults.minBundles,
+      minBundleSize: config.minBundleSize ?? defaults.minBundleSize,
+      maxParallelRequests:
+        config.maxParallelRequests ?? defaults.maxParallelRequests,
+    },
+    files: result.files,
+  };
+}
diff --git a/packages/core/core/src/BundlerRunner.js b/packages/core/core/src/BundlerRunner.js
index 62f3a42b8..5294dd2ec 100644
--- a/packages/core/core/src/BundlerRunner.js
+++ b/packages/core/core/src/BundlerRunner.js
@@ -1,6 +1,11 @@
 // @flow strict-local
 
-import type {Bundle as IBundle, Namer, FilePath} from '@parcel/types';
+import type {
+  Bundle as IBundle,
+  Namer,
+  FilePath,
+  ConfigOutput,
+} from '@parcel/types';
 import type {Bundle as InternalBundle, ParcelOptions} from './types';
 import type ParcelConfig from './ParcelConfig';
 import type WorkerFarm from '@parcel/workers';
@@ -57,9 +62,27 @@ export default class BundlerRunner {
       phase: 'bundling',
     });
 
+    let {plugin: bundler} = await this.config.getBundler();
+
+    let configResult: ?ConfigOutput;
+    if (bundler.loadConfig != null) {
+      try {
+        configResult = await nullthrows(bundler.loadConfig)({
+          options: this.pluginOptions,
+          logger: new PluginLogger({origin: this.config.getBundlerName()}),
+        });
+
+        // TODO: add invalidations once bundling is a request
+      } catch (e) {
+        throw new ThrowableDiagnostic({
+          diagnostic: errorToDiagnostic(e, this.config.getBundlerName()),
+        });
+      }
+    }
+
     let cacheKey;
     if (!this.options.disableCache) {
-      cacheKey = await this.getCacheKey(graph);
+      cacheKey = await this.getCacheKey(graph, configResult);
       let cachedBundleGraph = await this.options.cache.get(cacheKey);
       assertSignalNotAborted(signal);
 
@@ -76,11 +99,10 @@ export default class BundlerRunner {
       this.options,
     );
 
-    let {plugin: bundler} = await this.config.getBundler();
-
     try {
       await bundler.bundle({
         bundleGraph: mutableBundleGraph,
+        config: configResult?.config,
         options: this.pluginOptions,
         logger: new PluginLogger({origin: this.config.getBundlerName()}),
       });
@@ -96,6 +118,7 @@ export default class BundlerRunner {
     try {
       await bundler.optimize({
         bundleGraph: mutableBundleGraph,
+        config: configResult?.config,
         options: this.pluginOptions,
         logger: new PluginLogger({origin: this.config.getBundlerName()}),
       });
@@ -129,7 +152,7 @@ export default class BundlerRunner {
     return internalBundleGraph;
   }
 
-  async getCacheKey(assetGraph: AssetGraph) {
+  async getCacheKey(assetGraph: AssetGraph, configResult: ?ConfigOutput) {
     let name = this.config.getBundlerName();
     let {version} = await this.config.getBundler();
 
@@ -138,6 +161,7 @@ export default class BundlerRunner {
       name,
       version,
       hash: assetGraph.getHash(),
+      config: configResult?.config,
     });
   }
 
diff --git a/packages/core/integration-tests/test/integration/shared-many/yarn.lock b/packages/core/integration-tests/test/integration/shared-many/yarn.lock
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/core/types/index.js b/packages/core/types/index.js
index 21f3ffcb7..da68e271c 100644
--- a/packages/core/types/index.js
+++ b/packages/core/types/index.js
@@ -736,14 +736,25 @@ export type ResolveResult = {|
   +code?: string,
 |};
 
+export type ConfigOutput = {|
+  config: ConfigResult,
+  files: Array<File>,
+|};
+
 export type Bundler = {|
+  loadConfig?: ({|
+    options: PluginOptions,
+    logger: PluginLogger,
+  |}) => Async<ConfigOutput>,
   bundle({|
     bundleGraph: MutableBundleGraph,
+    config: ?ConfigResult,
     options: PluginOptions,
     logger: PluginLogger,
   |}): Async<void>,
   optimize({|
     bundleGraph: MutableBundleGraph,
+    config: ?ConfigResult,
     options: PluginOptions,
     logger: PluginLogger,
   |}): Async<void>,
diff --git a/packages/core/utils/src/schema.js b/packages/core/utils/src/schema.js
index 5d2dca950..7538441a4 100644
--- a/packages/core/utils/src/schema.js
+++ b/packages/core/utils/src/schema.js
@@ -10,6 +10,7 @@ export type SchemaEntity =
   | SchemaArray
   | SchemaBoolean
   | SchemaString
+  | SchemaNumber
   | SchemaEnum
   | SchemaOneOf
   | SchemaAllOf
@@ -40,6 +41,11 @@ export type SchemaString = {|
   __validate?: (val: string) => ?string,
   __type?: string,
 |};
+export type SchemaNumber = {|
+  type: 'number',
+  enum?: Array<number>,
+  __type?: string,
+|};
 export type SchemaEnum = {|
   enum: Array<mixed>,
 |};
@@ -173,6 +179,23 @@ function validateSchema(schema: SchemaEntity, data: mixed): Array<SchemaError> {
             }
             break;
           }
+          case 'number': {
+            // $FlowFixMe type was already checked
+            let value: number = dataNode;
+            if (schemaNode.enum) {
+              if (!schemaNode.enum.includes(value)) {
+                return {
+                  type: 'enum',
+                  dataType: 'value',
+                  dataPath,
+                  expectedValues: schemaNode.enum,
+                  actualValue: value,
+                  ancestors: schemaAncestors,
+                };
+              }
+            }
+            break;
+          }
           case 'object': {
             let results: Array<Array<SchemaError> | SchemaError> = [];
             let invalidProps;
@@ -384,7 +407,7 @@ validateSchema.diagnostic = function(
             .map(v => JSON.stringify(v))
             .join(', ')}?`;
         } else if (expectedValues.length > 0) {
-          message = `Possible values: ${expectedValues
+          message = `Possible values: ${e.expectedValues
             .map(v => JSON.stringify(v))
             .join(', ')}`;
         } else {


Test patch:
diff --git a/packages/core/integration-tests/test/integration/shared-many/package.json b/packages/core/integration-tests/test/integration/shared-many/package.json
new file mode 100644
index 000000000..d5121fefe
--- /dev/null
+++ b/packages/core/integration-tests/test/integration/shared-many/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "shared-many",
+  "private": true,
+  "@parcel/bundler-default": {
+    "http": 1
+  }
+}