diff --git a/packages/core/core/package.json b/packages/core/core/package.json index 63677b6061f..67fb61d364e 100644 --- a/packages/core/core/package.json +++ b/packages/core/core/package.json @@ -28,6 +28,7 @@ "@parcel/cache": "2.12.0", "@parcel/diagnostic": "2.12.0", "@parcel/events": "2.12.0", + "@parcel/feature-flags": "2.12.0", "@parcel/fs": "2.12.0", "@parcel/graph": "3.2.0", "@parcel/logger": "2.12.0", diff --git a/packages/core/core/src/public/PluginOptions.js b/packages/core/core/src/public/PluginOptions.js index 7ae8fdff268..ca9d6c42e7e 100644 --- a/packages/core/core/src/public/PluginOptions.js +++ b/packages/core/core/src/public/PluginOptions.js @@ -12,6 +12,7 @@ import type { import type {FileSystem} from '@parcel/fs'; import type {PackageManager} from '@parcel/package-manager'; import type {ParcelOptions} from '../types'; +import {type FeatureFlags} from '@parcel/feature-flags'; let parcelOptionsToPluginOptions: WeakMap = new WeakMap(); @@ -87,4 +88,8 @@ export default class PluginOptions implements IPluginOptions { get detailedReport(): ?DetailedReportOptions { return this.#options.detailedReport; } + + get featureFlags(): FeatureFlags { + return this.#options.featureFlags; + } } diff --git a/packages/core/core/src/resolveOptions.js b/packages/core/core/src/resolveOptions.js index ede438b117c..a907abcfa2d 100644 --- a/packages/core/core/src/resolveOptions.js +++ b/packages/core/core/src/resolveOptions.js @@ -25,6 +25,8 @@ import loadDotEnv from './loadDotEnv'; import {toProjectPath} from './projectPath'; import {getResolveFrom} from './requests/ParcelConfigRequest'; +import {DEFAULT_FEATURE_FLAGS} from '@parcel/feature-flags'; + // Default cache directory name const DEFAULT_CACHE_DIRNAME = '.parcel-cache'; const LOCK_FILE_NAMES = ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml']; @@ -218,6 +220,7 @@ export default async function resolveOptions( outputFormat: initialOptions?.defaultTargetOptions?.outputFormat, isLibrary: initialOptions?.defaultTargetOptions?.isLibrary, }, + featureFlags: {...DEFAULT_FEATURE_FLAGS, ...initialOptions?.featureFlags}, }; } diff --git a/packages/core/core/src/types.js b/packages/core/core/src/types.js index e6e9225cc5e..b057094be1c 100644 --- a/packages/core/core/src/types.js +++ b/packages/core/core/src/types.js @@ -32,6 +32,7 @@ import type {Cache} from '@parcel/cache'; import type {PackageManager} from '@parcel/package-manager'; import type {ProjectPath} from './projectPath'; import type {EventType} from '@parcel/watcher'; +import type {FeatureFlags} from '@parcel/feature-flags'; export type ParcelPluginNode = {| packageName: PackageName, @@ -316,6 +317,8 @@ export type ParcelOptions = {| +outputFormat?: OutputFormat, +isLibrary?: boolean, |}, + + +featureFlags: FeatureFlags, |}; export type AssetNode = {| diff --git a/packages/core/core/test/test-utils.js b/packages/core/core/test/test-utils.js index 4eda2d81f8d..b16481b99c8 100644 --- a/packages/core/core/test/test-utils.js +++ b/packages/core/core/test/test-utils.js @@ -49,6 +49,9 @@ export const DEFAULT_OPTIONS: ParcelOptions = { distDir: undefined, sourceMaps: false, }, + featureFlags: { + exampleFeature: false, + }, }; export const DEFAULT_ENV: Environment = createEnvironment({ diff --git a/packages/core/feature-flags/package.json b/packages/core/feature-flags/package.json new file mode 100644 index 00000000000..93e5e5a7ba2 --- /dev/null +++ b/packages/core/feature-flags/package.json @@ -0,0 +1,23 @@ +{ + "name": "@parcel/feature-flags", + "version": "2.12.0", + "description": "Blazing fast, zero configuration web application bundler", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "repository": { + "type": "git", + "url": "https://github.com/parcel-bundler/parcel.git" + }, + "main": "lib/index.js", + "source": "src/index.js", + "types": "index.d.ts", + "engines": { + "node": ">= 16.0.0" + } +} diff --git a/packages/core/feature-flags/src/index.js b/packages/core/feature-flags/src/index.js new file mode 100644 index 00000000000..ed8aa896ac2 --- /dev/null +++ b/packages/core/feature-flags/src/index.js @@ -0,0 +1,10 @@ +// @flow strict + +export type FeatureFlags = {| + // This feature flag mostly exists to test the feature flag system, and doesn't have any build/runtime effect + +exampleFeature: boolean, +|}; + +export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { + exampleFeature: false, +}; diff --git a/packages/core/integration-tests/test/feature-flags.js b/packages/core/integration-tests/test/feature-flags.js new file mode 100644 index 00000000000..dc9cdc51e3a --- /dev/null +++ b/packages/core/integration-tests/test/feature-flags.js @@ -0,0 +1,101 @@ +import assert from 'assert'; +import path from 'node:path'; +import {rimraf} from 'rimraf'; +import {bundle, run, overlayFS, fsFixture} from '@parcel/test-utils'; + +describe('feature flags', () => { + let dir = path.join(__dirname, 'feature-flags-fixture'); + beforeEach(async () => { + await rimraf(dir); + await overlayFS.mkdirp(dir); + await fsFixture(overlayFS, dir)` + yarn.lock: + // required for .parcelrc to work + + package.json: + { + "name": "feature-flags-fixture", + "version": "1.0.0" + } + + index.js: + module.exports = "MARKER"; + + .parcelrc: + { + extends: "@parcel/config-default", + transformers: { + '*.js': ['./transformer.js', '...'] + }, + } + + transformer.js: + const {Transformer} = require('@parcel/plugin'); + module.exports = new Transformer({ + async transform({asset, options}) { + const code = await asset.getCode(); + if (code.includes('MARKER') && options.featureFlags.exampleFeature) { + asset.setCode(code.replace('MARKER', 'REPLACED')); + } + return [asset]; + } + }); +`; + }); + + it('flag should be available in plugins and set from options', async () => { + await overlayFS.mkdirp(dir); + + const b = await bundle(path.join(dir, 'index.js'), { + inputFS: overlayFS, + featureFlags: {exampleFeature: true}, + }); + const output = await run(b); + + assert( + output.includes('REPLACED'), + `Expected ${output} to contain 'REPLACED'`, + ); + }); + + it('flag defaults should be available in plugins if not set from options', async () => { + await overlayFS.mkdirp(dir); + + const b = await bundle(path.join(dir, 'index.js'), { + inputFS: overlayFS, + }); + const output = await run(b); + + assert( + !output.includes('REPLACED'), + `Expected ${output} to NOT contain 'REPLACED'`, + ); + }); + + it('cache should invalidate on flag switch', async () => { + await overlayFS.mkdirp(dir); + + const b = await bundle(path.join(dir, 'index.js'), { + inputFS: overlayFS, + shouldDisableCache: false, + featureFlags: {exampleFeature: true}, + }); + const output = await run(b); + + assert( + output.includes('REPLACED'), + `Expected ${output} to contain 'REPLACED'`, + ); + + const b2 = await bundle(path.join(dir, 'index.js'), { + inputFS: overlayFS, + shouldDisableCache: false, + featureFlags: {exampleFeature: false}, + }); + const output2 = await run(b2); + assert( + !output2.includes('REPLACED'), + `Expected ${output} to NOT contain 'REPLACED'`, + ); + }); +}); diff --git a/packages/core/types/index.js b/packages/core/types/index.js index b250b58972a..2a83bf12177 100644 --- a/packages/core/types/index.js +++ b/packages/core/types/index.js @@ -15,6 +15,7 @@ import type {Cache} from '@parcel/cache'; import type {AST as _AST, ConfigResult as _ConfigResult} from './unsafe'; import type {TraceMeasurement} from '@parcel/profiler'; import type {EventType} from '@parcel/watcher'; +import type {FeatureFlags} from '@parcel/feature-flags'; /** Plugin-specific AST, any */ export type AST = _AST; @@ -333,6 +334,8 @@ export type InitialParcelOptions = {| resolveFrom: FilePath, |}>, + +featureFlags?: FeatureFlags, + // throwErrors // global? |}; @@ -359,6 +362,7 @@ export interface PluginOptions { +packageManager: PackageManager; +instanceId: string; +detailedReport: ?DetailedReportOptions; + +featureFlags: FeatureFlags; } export type ServerOptions = {| diff --git a/packages/core/types/package.json b/packages/core/types/package.json index 7147ad2d025..76edc3a274e 100644 --- a/packages/core/types/package.json +++ b/packages/core/types/package.json @@ -18,6 +18,7 @@ "dependencies": { "@parcel/cache": "2.12.0", "@parcel/diagnostic": "2.12.0", + "@parcel/feature-flags": "2.12.0", "@parcel/fs": "2.12.0", "@parcel/package-manager": "2.12.0", "@parcel/source-map": "^2.1.1", diff --git a/packages/reporters/cli/package.json b/packages/reporters/cli/package.json index 2c7b678c7d0..8be6796b2e5 100644 --- a/packages/reporters/cli/package.json +++ b/packages/reporters/cli/package.json @@ -38,6 +38,7 @@ "term-size": "^2.2.1" }, "devDependencies": { + "@parcel/feature-flags": "2.12.0", "filesize": "^6.1.0", "nullthrows": "^1.1.1", "ora": "^5.2.0", diff --git a/packages/reporters/cli/test/CLIReporter.test.js b/packages/reporters/cli/test/CLIReporter.test.js index 3bf381f0515..70d43eefe9b 100644 --- a/packages/reporters/cli/test/CLIReporter.test.js +++ b/packages/reporters/cli/test/CLIReporter.test.js @@ -10,6 +10,7 @@ import {NodePackageManager} from '@parcel/package-manager'; import stripAnsi from 'strip-ansi'; import * as bundleReport from '../src/bundleReport'; import * as render from '../src/render'; +import {DEFAULT_FEATURE_FLAGS} from '@parcel/feature-flags'; const EMPTY_OPTIONS = { cacheDir: '.parcel-cache', @@ -36,6 +37,7 @@ const EMPTY_OPTIONS = { detailedReport: { assetsPerBundle: 10, }, + featureFlags: DEFAULT_FEATURE_FLAGS, }; describe('CLIReporter', () => {