diff --git a/.mocharc.json b/.mocharc.json index b75f693a3..0ed4c64cb 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,5 +1,5 @@ { - "spec": "packages/*/!(integration-tests)/test/{*.js,**/*.{test,spec}.js}", + "spec": "packages/*/!(integration-tests)/test/{*.ts,**/*.{test,spec}.ts}", "require": [ "@atlaspack/babel-register", "@atlaspack/test-utils/src/mochaSetup.js" diff --git a/.prettierrc b/.prettierrc index 6e76d2a1a..222142ad4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,7 +3,7 @@ "endOfLine": "lf", "singleQuote": true, "trailingComma": "all", - "arrowParens": "avoid", + "arrowParens": "always", "overrides": [ { "files": [ diff --git a/flow-libs/deasync.js b/flow-libs/deasync.js deleted file mode 100644 index c9973c8f2..000000000 --- a/flow-libs/deasync.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow - -declare module 'deasync' { - declare module.exports: { - // TODO: Main callable signature - loopWhile(() => boolean): void, - runLoopOnce(): void, - sleep(sleepTimeMs: number): void, - ... - }; -} diff --git a/flow-typed/npm/@swc/core_v1.x.x.js b/flow-typed/npm/@swc/core_v1.x.x.js deleted file mode 100644 index 41d055de3..000000000 --- a/flow-typed/npm/@swc/core_v1.x.x.js +++ /dev/null @@ -1,3420 +0,0 @@ -// @flow - -declare module '@swc/core' { - /** - * Flowtype definitions for types - * Generated by Flowgen from a Typescript Definition - * Flowgen v1.21.0 - */ - - // see https://gist.github.com/thecotne/6e5969f4aaf8f253985ed36b30ac9fe0 - declare type $FlowGen$If = $Call< - ((true, Then, Else) => Then) & ((false, Then, Else) => Else), - X, - Then, - Else, - >; - - declare type $FlowGen$Assignable = $Call< - ((...r: [B]) => true) & ((...r: [A]) => false), - A, - >; - - declare export type Plugin = {| - (module: Program): Program, - |}; - declare export type ParseOptions = {| - ...ParserConfig, - ...{| - comments?: boolean, - script?: boolean, - - /** - * Defaults to es3. - */ - target?: JscTarget, - |}, - |}; - declare export type TerserEcmaVersion = 5 | 2015 | 2016 | string | number; - declare export type JsMinifyOptions = {| - compress?: TerserCompressOptions | boolean, - format?: {| - ...JsFormatOptions /*...ToSnakeCaseProperties*/, - |}, - mangle?: TerserMangleOptions | boolean, - ecma?: TerserEcmaVersion, - keep_classnames?: boolean, - keep_fnames?: boolean, - module?: boolean, - safari10?: boolean, - toplevel?: boolean, - sourceMap?: boolean, - outputPath?: string, - inlineSourcesContent?: boolean, - |}; - - /** - * @example ToSnakeCase<'indentLevel'> == 'indent_level' - */ - // declare type ToSnakeCase = $FlowGen$If<$FlowGen$Assignable,undefined: /* NO PRINT IMPLEMENTED: TemplateLiteralType */ any,T>; - /** - * @example ToSnakeCaseProperties<{indentLevel: 3}> == {indent_level: 3} - */ - // declare type ToSnakeCaseProperties = $ObjMapi(K) => $ElementType>; - /** - * These properties are mostly not implemented yet, - * but it exists to support passing terser config to swc minify - * without modification. - */ - declare export type JsFormatOptions = {| - /** - * Currently noop. - * @default false - * @alias ascii_only - */ - asciiOnly?: boolean, - - /** - * Currently noop. - * @default false - */ - beautify?: boolean, - - /** - * Currently noop. - * @default false - */ - braces?: boolean, - - /** - * - `false`: removes all comments - * - `'some'`: preserves some comments - * - `'all'`: preserves all comments - * @default false - */ - comments?: false | 'some' | 'all', - - /** - * Currently noop. - * @default 5 - */ - ecma?: TerserEcmaVersion, - - /** - * Currently noop. - * @alias indent_level - */ - indentLevel?: number, - - /** - * Currently noop. - * @alias indent_start - */ - indentStart?: number, - - /** - * Currently noop. - * @alias inline_script - */ - inlineScript?: number, - - /** - * Currently noop. - * @alias keep_numbers - */ - keepNumbers?: number, - - /** - * Currently noop. - * @alias keep_quoted_props - */ - keepQuotedProps?: boolean, - - /** - * Currently noop. - * @alias max_line_len - */ - maxLineLen?: number | false, - - /** - * Currently noop. - */ - preamble?: string, - - /** - * Currently noop. - * @alias quote_keys - */ - quoteKeys?: boolean, - - /** - * Currently noop. - * @alias quote_style - */ - quoteStyle?: boolean, - - /** - * Currently noop. - * @alias preserve_annotations - */ - preserveAnnotations?: boolean, - - /** - * Currently noop. - */ - safari10?: boolean, - - /** - * Currently noop. - */ - semicolons?: boolean, - - /** - * Currently noop. - */ - shebang?: boolean, - - /** - * Currently noop. - */ - webkit?: boolean, - - /** - * Currently noop. - * @alias wrap_iife - */ - wrapIife?: boolean, - - /** - * Currently noop. - * @alias wrap_func_args - */ - wrapFuncArgs?: boolean, - |}; - declare export type TerserCompressOptions = {| - arguments?: boolean, - arrows?: boolean, - booleans?: boolean, - booleans_as_integers?: boolean, - collapse_vars?: boolean, - comparisons?: boolean, - computed_props?: boolean, - conditionals?: boolean, - dead_code?: boolean, - defaults?: boolean, - directives?: boolean, - drop_console?: boolean, - drop_debugger?: boolean, - ecma?: TerserEcmaVersion, - evaluate?: boolean, - expression?: boolean, - global_defs?: any, - hoist_funs?: boolean, - hoist_props?: boolean, - hoist_vars?: boolean, - ie8?: boolean, - if_return?: boolean, - inline?: 0 | 1 | 2 | 3, - join_vars?: boolean, - keep_classnames?: boolean, - keep_fargs?: boolean, - keep_fnames?: boolean, - keep_infinity?: boolean, - loops?: boolean, - negate_iife?: boolean, - passes?: number, - properties?: boolean, - pure_getters?: any, - pure_funcs?: string[], - reduce_funcs?: boolean, - reduce_vars?: boolean, - sequences?: any, - side_effects?: boolean, - switches?: boolean, - top_retain?: any, - toplevel?: any, - typeofs?: boolean, - unsafe?: boolean, - unsafe_passes?: boolean, - unsafe_arrows?: boolean, - unsafe_comps?: boolean, - unsafe_function?: boolean, - unsafe_math?: boolean, - unsafe_symbols?: boolean, - unsafe_methods?: boolean, - unsafe_proto?: boolean, - unsafe_regexp?: boolean, - unsafe_undefined?: boolean, - unused?: boolean, - const_to_let?: boolean, - module?: boolean, - |}; - declare export type TerserMangleOptions = {| - props?: TerserManglePropertiesOptions, - toplevel?: boolean, - keep_classnames?: boolean, - keep_fnames?: boolean, - keep_private_props?: boolean, - ie8?: boolean, - safari10?: boolean, - reserved?: string[], - |}; - declare export type TerserManglePropertiesOptions = {||}; - - /** - * Programmatic options. - */ - declare export type Options = {| - ...$Exact, - - /** - * If true, a file is parsed as a script instead of module. - */ - script?: boolean, - - /** - * The working directory that all paths in the programmatic - * options will be resolved relative to. - * - * Defaults to `process.cwd()`. - */ - cwd?: string, - caller?: CallerOptions, - - /** - * The filename associated with the code currently being compiled, - * if there is one. The filename is optional, but not all of Swc's - * functionality is available when the filename is unknown, because a - * subset of options rely on the filename for their functionality. - * - * The three primary cases users could run into are: - * - * - The filename is exposed to plugins. Some plugins may require the - * presence of the filename. - * - Options like "test", "exclude", and "ignore" require the filename - * for string/RegExp matching. - * - .swcrc files are loaded relative to the file being compiled. - * If this option is omitted, Swc will behave as if swcrc: false has been set. - */ - filename?: string, - - /** - * The initial path that will be processed based on the "rootMode" to - * determine the conceptual root folder for the current Swc project. - * This is used in two primary cases: - * - * - The base directory when checking for the default "configFile" value - * - The default value for "swcrcRoots". - * - * Defaults to `opts.cwd` - */ - root?: string, - - /** - * This option, combined with the "root" value, defines how Swc chooses - * its project root. The different modes define different ways that Swc - * can process the "root" value to get the final project root. - * - * "root" - Passes the "root" value through as unchanged. - * "upward" - Walks upward from the "root" directory, looking for a directory - * containing a swc.config.js file, and throws an error if a swc.config.js - * is not found. - * "upward-optional" - Walk upward from the "root" directory, looking for - * a directory containing a swc.config.js file, and falls back to "root" - * if a swc.config.js is not found. - * - * - * "root" is the default mode because it avoids the risk that Swc - * will accidentally load a swc.config.js that is entirely outside - * of the current project folder. If you use "upward-optional", - * be aware that it will walk up the directory structure all the - * way to the filesystem root, and it is always possible that someone - * will have a forgotten swc.config.js in their home directory, - * which could cause unexpected errors in your builds. - * - * - * Users with monorepo project structures that run builds/tests on a - * per-package basis may well want to use "upward" since monorepos - * often have a swc.config.js in the project root. Running Swc - * in a monorepo subdirectory without "upward", will cause Swc - * to skip loading any swc.config.js files in the project root, - * which can lead to unexpected errors and compilation failure. - */ - rootMode?: 'root' | 'upward' | 'upward-optional', - - /** - * The current active environment used during configuration loading. - * This value is used as the key when resolving "env" configs, - * and is also available inside configuration functions, plugins, - * and presets, via the api.env() function. - * - * Defaults to `process.env.SWC_ENV || process.env.NODE_ENV || "development"` - */ - envName?: string, - - /** - * Defaults to searching for a default `.swcrc` file, but can - * be passed the path of any JS or JSON5 config file. - * - * - * NOTE: This option does not affect loading of .swcrc files, - * so while it may be tempting to do configFile: "./foo/.swcrc", - * it is not recommended. If the given .swcrc is loaded via the - * standard file-relative logic, you'll end up loading the same - * config file twice, merging it with itself. If you are linking - * a specific config file, it is recommended to stick with a - * naming scheme that is independent of the "swcrc" name. - * - * Defaults to `path.resolve(opts.root, ".swcrc")` - */ - configFile?: string | boolean, - - /** - * true will enable searching for configuration files relative to the "filename" provided to Swc. - * - * A swcrc value passed in the programmatic options will override one set within a configuration file. - * - * Note: .swcrc files are only loaded if the current "filename" is inside of - * a package that matches one of the "swcrcRoots" packages. - * - * - * Defaults to true as long as the filename option has been specified - */ - swcrc?: boolean, - - /** - * By default, Babel will only search for .babelrc files within the "root" package - * because otherwise Babel cannot know if a given .babelrc is meant to be loaded, - * or if it's "plugins" and "presets" have even been installed, since the file - * being compiled could be inside node_modules, or have been symlinked into the project. - * - * - * This option allows users to provide a list of other packages that should be - * considered "root" packages when considering whether to load .babelrc files. - * - * - * For example, a monorepo setup that wishes to allow individual packages - * to have their own configs might want to do - * - * - * - * Defaults to `opts.root` - */ - swcrcRoots?: boolean | MatchPattern | MatchPattern[], - - /** - * `true` will attempt to load an input sourcemap from the file itself, if it - * contains a //# sourceMappingURL=... comment. If no map is found, or the - * map fails to load and parse, it will be silently discarded. - * - * If an object is provided, it will be treated as the source map object itself. - * - * Defaults to `true`. - */ - inputSourceMap?: boolean | string, - - /** - * The name to use for the file inside the source map object. - * - * Defaults to `path.basename(opts.filenameRelative)` when available, or `"unknown"`. - */ - sourceFileName?: string, - - /** - * The sourceRoot fields to set in the generated source map, if one is desired. - */ - sourceRoot?: string, - plugin?: Plugin, - isModule?: boolean | 'unknown', - - /** - * Destination path. Note that this value is used only to fix source path - * of source map files and swc does not write output to this path. - */ - outputPath?: string, - |}; - declare type BundleInput = BundleOptions | BundleOptions[]; - declare export function compileBundleOptions( - config: BundleInput | string | void, - ): Promise; - - /** - * Usage: In `spack.config.js` / `spack.config.ts`, you can utilize type annotations (to get autocompletions) like - * - * ```ts - * import { config } from '@swc/core/spack'; - * - * export default config({ - * name: 'web', - * }); - * ``` - */ - declare export function config(c: BundleInput): BundleInput; - declare export type BundleOptions = {| - ...$Exact, - - workingDir?: string, - |}; - - /** - * `spack.config,js` - */ - declare export type SpackConfig = {| - /** - * @default process.env.NODE_ENV - */ - mode?: Mode, - target?: Target, - entry: EntryConfig, - output: OutputConfig, - module: ModuleConfig, - options?: Options, - - /** - * Modules to exclude from bundle. - */ - externalModules?: string[], - |}; - declare export type OutputConfig = {| - name: string, - path: string, - |}; - declare export type Mode = 'production' | 'development' | 'none'; - declare export type Target = 'browser' | 'node'; - declare export type EntryConfig = - | string - | string[] - | { - [name: string]: string, - }; - - declare export type CallerOptions = {| - name: string, - [key: string]: any, - |}; - declare export type Swcrc = Config | Config[]; - /** - * .swcrc - */ - declare export type Config = {| - /** - * Note: The type is string because it follows rust's regex syntax. - */ - test?: string | string[], - - /** - * Note: The type is string because it follows rust's regex syntax. - */ - exclude?: string | string[], - env?: EnvConfig, - jsc?: JscConfig, - module?: ModuleConfig, - minify?: boolean, - - /** - * - true to generate a sourcemap for the code and include it in the result object. - * - "inline" to generate a sourcemap and append it as a data URL to the end of the code, but not include it in the result object. - * - * `swc-cli` overloads some of these to also affect how maps are written to disk: - * - * - true will write the map to a .map file on disk - * - "inline" will write the file directly, so it will have a data: containing the map - * - Note: These options are bit weird, so it may make the most sense to just use true - * and handle the rest in your own code, depending on your use case. - */ - sourceMaps?: boolean | 'inline', - inlineSourcesContent?: boolean, - |}; - - /** - * Configuration ported from babel-preset-env - */ - declare export type EnvConfig = {| - mode?: 'usage' | 'entry', - debug?: boolean, - dynamicImport?: boolean, - loose?: boolean, - skip?: string[], - include?: string[], - exclude?: string[], - - /** - * The version of the used core js. - */ - coreJs?: string, - targets?: any, - path?: string, - shippedProposals?: boolean, - - /** - * Enable all transforms - */ - forceAllTransforms?: boolean, - |}; - declare export type JscConfig = {| - loose?: boolean, - - /** - * Defaults to EsParserConfig - */ - parser?: ParserConfig, - transform?: TransformConfig, - - /** - * Use `@swc/helpers` instead of inline helpers. - */ - externalHelpers?: boolean, - - /** - * Defaults to `es3` (which enabled **all** pass). - */ - target?: JscTarget, - - /** - * Keep class names. - */ - keepClassNames?: boolean, - experimental?: {| - optimizeHygiene?: boolean, - keepImportAssertions?: boolean, - - /** - * Specify the location where SWC stores its intermediate cache files. - * Currently only transform plugin uses this. If not specified, SWC will - * create `.swc` directories. - */ - cacheRoot?: string, - - /** - * List of custom transform plugins written in WebAssembly. - * First parameter of tuple indicates the name of the plugin - it can be either - * a name of the npm package can be resolved, or absolute path to .wasm binary. - * - * Second parameter of tuple is JSON based configuration for the plugin. - */ - plugins?: Array<[string, {[key: string]: any}]>, - |}, - baseUrl?: string, - paths?: { - [from: string]: string[], - }, - minify?: JsMinifyOptions, - preserveAllComments?: boolean, - |}; - declare export type JscTarget = - | 'es3' - | 'es5' - | 'es2015' - | 'es2016' - | 'es2017' - | 'es2018' - | 'es2019' - | 'es2020' - | 'es2021' - | 'es2022' - | 'esnext'; - declare export type ParserConfig = TsParserConfig | EsParserConfig; - declare export type TsParserConfig = {| - syntax: 'typescript', - - /** - * Defaults to `false`. - */ - tsx?: boolean, - - /** - * Defaults to `false`. - */ - decorators?: boolean, - - /** - * Defaults to `false` - */ - dynamicImport?: boolean, - |}; - declare export type EsParserConfig = {| - syntax: 'ecmascript', - - /** - * Defaults to false. - */ - jsx?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - numericSeparator?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - classPrivateProperty?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - privateMethod?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - classProperty?: boolean, - - /** - * Defaults to `false` - */ - functionBind?: boolean, - - /** - * Defaults to `false` - */ - decorators?: boolean, - - /** - * Defaults to `false` - */ - decoratorsBeforeExport?: boolean, - - /** - * Defaults to `false` - */ - exportDefaultFrom?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - exportNamespaceFrom?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - dynamicImport?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - nullishCoalescing?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - optionalChaining?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - importMeta?: boolean, - - /** - * @deprecated Always true because it's in ecmascript spec. - */ - topLevelAwait?: boolean, - - /** - * Defaults to `false` - */ - importAssertions?: boolean, - |}; - - /** - * Options for transform. - */ - declare export type TransformConfig = {| - /** - * Effective only if `syntax` supports ƒ. - */ - react?: ReactConfig, - constModules?: ConstModulesConfig, - - /** - * Defaults to null, which skips optimizer pass. - */ - optimizer?: OptimizerConfig, - - /** - * https://swc.rs/docs/configuring-swc.html#jsctransformlegacydecorator - */ - legacyDecorator?: boolean, - - /** - * https://swc.rs/docs/configuring-swc.html#jsctransformdecoratormetadata - */ - decoratorMetadata?: boolean, - treatConstEnumAsEnum?: boolean, - useDefineForClassFields?: boolean, - |}; - declare export type ReactConfig = {| - /** - * Replace the function used when compiling JSX expressions. - * - * Defaults to `React.createElement`. - */ - pragma?: string, - - /** - * Replace the component used when compiling JSX fragments. - * - * Defaults to `React.Fragment` - */ - pragmaFrag?: string, - - /** - * Toggles whether or not to throw an error if a XML namespaced tag name is used. For example: - * `` - * - * Though the JSX spec allows this, it is disabled by default since React's - * JSX does not currently have support for it. - */ - throwIfNamespace?: boolean, - - /** - * Toggles plugins that aid in development, such as @swc/plugin-transform-react-jsx-self - * and @swc/plugin-transform-react-jsx-source. - * - * Defaults to `false`, - */ - development?: boolean, - - /** - * Use `Object.assign()` instead of `_extends`. Defaults to false. - */ - useBuiltins?: boolean, - - /** - * Enable fast refresh feature for React app - */ - refresh?: boolean, - - /** - * jsx runtime - */ - runtime?: 'automatic' | 'classic', - - /** - * Declares the module specifier to be used for importing the `jsx` and `jsxs` factory functions when using `runtime` 'automatic' - */ - importSource?: string, - |}; - - /** - * - `import { DEBUG } from '@ember/env-flags';` - * - `import { FEATURE_A, FEATURE_B } from '@ember/features';` - * - * See: https://github.com/swc-project/swc/issues/18#issuecomment-466272558 - */ - declare export type ConstModulesConfig = {| - globals?: { - [module: string]: { - [name: string]: string, - }, - }, - |}; - declare export type OptimizerConfig = {| - simplify?: boolean, - globals?: GlobalPassOption, - jsonify?: {| - minCost: number, - |}, - |}; - - /** - * Options for inline-global pass. - */ - declare export type GlobalPassOption = {| - /** - * Global variables that should be inlined with passed value. - * - * e.g. `{ __DEBUG__: true }` - */ - vars?: {[key: string]: string}, - - /** - * Names of environment variables that should be inlined with the value of corresponding env during build. - * - * Defaults to `["NODE_ENV", "SWC_ENV"]` - */ - envs?: string[], - - /** - * Replaces typeof calls for passed variables with corresponding value - * - * e.g. `{ window: 'object' }` - */ - typeofs?: {[key: string]: string}, - |}; - declare export type ModuleConfig = - | Es6Config - | CommonJsConfig - | UmdConfig - | AmdConfig - | NodeNextConfig - | SystemjsConfig; - declare export type BaseModuleConfig = {| - /** - * By default, when using exports with babel a non-enumerable `__esModule` - * property is exported. In some cases this property is used to determine - * if the import is the default export or if it contains the default export. - * - * In order to prevent the __esModule property from being exported, you - * can set the strict option to true. - * - * Defaults to `false`. - */ - strict?: boolean, - - /** - * Emits 'use strict' directive. - * - * Defaults to `true`. - */ - strictMode?: boolean, - - /** - * Changes Babel's compiled import statements to be lazily evaluated when their imported bindings are used for the first time. - * - * This can improve initial load time of your module because evaluating dependencies up - * front is sometimes entirely un-necessary. This is especially the case when implementing - * a library module. - * - * - * The value of `lazy` has a few possible effects: - * - * - `false` - No lazy initialization of any imported module. - * - `true` - Do not lazy-initialize local `./foo` imports, but lazy-init `foo` dependencies. - * - * Local paths are much more likely to have circular dependencies, which may break if loaded lazily, - * so they are not lazy by default, whereas dependencies between independent modules are rarely cyclical. - * - * - `Array` - Lazy-initialize all imports with source matching one of the given strings. - * - * ----- - * - * The two cases where imports can never be lazy are: - * - * - `import "foo";` - * - * Side-effect imports are automatically non-lazy since their very existence means - * that there is no binding to later kick off initialization. - * - * - `export * from "foo"` - * - * Re-exporting all names requires up-front execution because otherwise there is no - * way to know what names need to be exported. - * - * Defaults to `false`. - */ - lazy?: boolean | string[], - - /** - * @deprecated Use the `importInterop` option instead. - * - * By default, when using exports with swc a non-enumerable __esModule property is exported. - * This property is then used to determine if the import is the default export or if - * it contains the default export. - * - * In cases where the auto-unwrapping of default is not needed, you can set the noInterop option - * to true to avoid the usage of the interopRequireDefault helper (shown in inline form above). - * - * Defaults to `false`. - */ - noInterop?: boolean, - - /** - * Defaults to `swc`. - * - * CommonJS modules and ECMAScript modules are not fully compatible. - * However, compilers, bundlers and JavaScript runtimes developed different strategies - * to make them work together as well as possible. - * - * - `swc` (alias: `babel`) - * - * When using exports with `swc` a non-enumerable `__esModule` property is exported - * This property is then used to determine if the import is the default export - * or if it contains the default export. - * - * ```javascript - * import foo from "foo"; - * import { bar } from "bar"; - * foo; - * bar; - * - * // Is compiled to ... - * - * "use strict"; - * - * function _interopRequireDefault(obj) { - * return obj && obj.__esModule ? obj : { default: obj }; - * } - * - * var _foo = _interopRequireDefault(require("foo")); - * var _bar = require("bar"); - * - * _foo.default; - * _bar.bar; - * ``` - * - * When this import interop is used, if both the imported and the importer module are compiled - * with swc they behave as if none of them was compiled. - * - * This is the default behavior. - * - * - `node` - * - * When importing CommonJS files (either directly written in CommonJS, or generated with a compiler) - * Node.js always binds the `default` export to the value of `module.exports`. - * - * ```javascript - * import foo from "foo"; - * import { bar } from "bar"; - * foo; - * bar; - * - * // Is compiled to ... - * - * "use strict"; - * - * var _foo = require("foo"); - * var _bar = require("bar"); - * - * _foo; - * _bar.bar; - * ``` - * This is not exactly the same as what Node.js does since swc allows accessing any property of `module.exports` - * as a named export, while Node.js only allows importing statically analyzable properties of `module.exports`. - * However, any import working in Node.js will also work when compiled with swc using `importInterop: "node"`. - * - * - `none` - * - * If you know that the imported file has been transformed with a compiler that stores the `default` export on - * `exports.default` (such as swc or Babel), you can safely omit the `_interopRequireDefault` helper. - * - * ```javascript - * import foo from "foo"; - * import { bar } from "bar"; - * foo; - * bar; - * - * // Is compiled to ... - * - * "use strict"; - * - * var _foo = require("foo"); - * var _bar = require("bar"); - * - * _foo.default; - * _bar.bar; - * ``` - */ - importInterop?: 'swc' | 'babel' | 'node' | 'none', - - /** - * If set to true, dynamic imports will be preserved. - */ - ignoreDynamic?: boolean, - allowTopLevelThis?: boolean, - preserveImportMeta?: boolean, - |}; - declare export type Es6Config = {| - ...$Exact, - - type: 'es6', - |}; - declare export type NodeNextConfig = {| - ...$Exact, - - type: 'nodenext', - |}; - declare export type CommonJsConfig = {| - ...$Exact, - - type: 'commonjs', - |}; - declare export type UmdConfig = {| - ...$Exact, - - type: 'umd', - globals?: { - [key: string]: string, - }, - |}; - declare export type AmdConfig = {| - ...$Exact, - - type: 'amd', - moduleId?: string, - |}; - declare export type SystemjsConfig = {| - type: 'systemjs', - allowTopLevelThis?: boolean, - |}; - declare export type Output = {| - /** - * Transformed code - */ - code: string, - - /** - * Sourcemap (**not** base64 encoded) - */ - map?: string, - |}; - declare export type MatchPattern = {||}; - - /** - * Version of the swc binding. - */ - declare export var version: string; - declare export function plugins(ps: Plugin[]): Plugin; - declare export class Compiler { - minify(src: string, opts?: JsMinifyOptions): Promise; - minifySync(src: string, opts?: JsMinifyOptions): Output; - parse( - src: string, - options: {| - ...ParseOptions, - ...{| - isModule: false, - |}, - |}, - ): Promise -// -// Console output:
-//
-// -// `; -// } - -// listen here instead of attaching temporary 'message' event listeners to self -let messageProxy = new EventTarget(); - -self.addEventListener('message', evt => { - let parentId = evt.source.id; - let {type, data, id} = evt.data; - if (type === 'setFS') { - // called by worker - evt.source.postMessage({id}); - pages.set(parentId, data); - } else if (type === 'getID') { - evt.source.postMessage({id, data: parentId}); - } else if (type === 'hmrUpdate') { - // called by worker - parentPorts.set(parentId, evt.source); - let clientId = parentToIframe.get(parentId); - let send = - (clientId != null ? sendToIFrame.get(clientId) : null) ?? lastHMRStream; - send?.(data); - evt.source.postMessage({id}); - } else { - let wrapper = new Event(evt.type); - // $FlowFixMe - wrapper.data = evt.data; - messageProxy.dispatchEvent(wrapper); - } -}); - -let encodeUTF8 = new TextEncoder(); - -self.addEventListener('fetch', evt => { - let url = new URL(evt.request.url); - let {clientId} = evt; - let parentId; - if (!clientId && url.searchParams.has('parentId')) { - clientId = evt.resultingClientId ?? evt.targetClientId; - parentId = nullthrows(url.searchParams.get('parentId')); - parentToIframe.set(parentId, clientId); - iframeToParent.set(clientId, parentId); - } else { - parentId = iframeToParent.get(evt.clientId); - } - if (parentId == null && isSafari) { - parentId = [...pages.keys()].slice(-1)[0]; - } - - if (parentId != null) { - if ( - evt.request.headers.get('Accept') === 'text/event-stream' && - url.pathname === '/__parcel_hmr' - ) { - let stream = new ReadableStream({ - start: controller => { - let cb = data => { - let chunk = `data: ${JSON.stringify(data)}\n\n`; - controller.enqueue(encodeUTF8.encode(chunk)); - }; - sendToIFrame.set(clientId, cb); - lastHMRStream = cb; - }, - }); - - evt.respondWith( - new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Transfer-Encoding': 'chunked', - Connection: 'keep-alive', - ...SECURITY_HEADERS, - }, - }), - ); - } else if (url.pathname.startsWith('/__parcel_hmr/')) { - evt.respondWith( - (async () => { - let port = parentId != null ? parentPorts.get(parentId) : null; - - if (port == null) { - return new Response(null, {status: 500}); - } - - let [type, content] = await sendMsg( - port, - 'hmrAssetSource', - url.pathname.slice('/__parcel_hmr/'.length), - ); - return new Response(content, { - headers: { - 'Content-Type': - (MIME.get(type) ?? 'application/octet-stream') + - '; charset=utf-8', - 'Cache-Control': 'no-store', - ...SECURITY_HEADERS, - }, - }); - })(), - ); - } else if (url.pathname.startsWith('/__repl_dist/')) { - let filename = url.pathname.slice('/__repl_dist/'.length); - let file = pages.get(parentId)?.[filename]; - if (file == null) { - console.error('requested missing file', parentId, filename, pages); - } - - evt.respondWith( - new Response(file, { - headers: { - 'Content-Type': - (MIME.get(extname(filename)) ?? 'application/octet-stream') + - '; charset=utf-8', - 'Cache-Control': 'no-store', - ...SECURITY_HEADERS, - }, - }), - ); - } - } -}); - -function extname(filename) { - return filename.slice(filename.lastIndexOf('.') + 1); -} - -function removeNonExistingKeys(existing, map) { - for (let id of map.keys()) { - if (!existing.has(id)) { - map.delete(id); - } - } -} -setInterval(async () => { - let existingClients = new Set((await self.clients.matchAll()).map(c => c.id)); - - removeNonExistingKeys(existingClients, pages); - removeNonExistingKeys(existingClients, sendToIFrame); - removeNonExistingKeys(existingClients, parentToIframe); - removeNonExistingKeys(existingClients, iframeToParent); -}, 20000); - -function sendMsg(target, type, data, transfer) { - let id = uuidv4(); - return new Promise(res => { - let handler = (evt: MessageEvent) => { - // $FlowFixMe - if (evt.data.id === id) { - messageProxy.removeEventListener('message', handler); - // $FlowFixMe - res(evt.data.data); - } - }; - messageProxy.addEventListener('message', handler); - target.postMessage({type, data, id}, transfer); - }); -} -function uuidv4() { - return (String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace( - /[018]/g, - // $FlowFixMe - (c: number) => - ( - c ^ - // $FlowFixMe - (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) - ).toString(16), - ); -} diff --git a/packages/dev/repl/src/sw.ts b/packages/dev/repl/src/sw.ts new file mode 100644 index 000000000..b18c5dd16 --- /dev/null +++ b/packages/dev/repl/src/sw.ts @@ -0,0 +1,277 @@ +/* eslint-disable no-restricted-globals */ +import nullthrows from 'nullthrows'; + +let isSafari = + /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); +// @ts-expect-error - TS7034 - Variable 'lastHMRStream' implicitly has type 'any' in some locations where its type cannot be determined. +let lastHMRStream; + +type ClientId = string; +type ParentId = string; + +let sendToIFrame = new Map void>(); +let pages = new Map< + ParentId, + { + [key: string]: string; + } +>(); +let parentPorts = new Map(); +let parentToIframe = new Map(); +let iframeToParent = new Map(); + +// @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. +global.parentPorts = parentPorts; +// @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. +global.parentToIframe = parentToIframe; +// @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. +global.iframeToParent = iframeToParent; + +const SECURITY_HEADERS = { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', +} as const; + +const MIME = new Map([ + ['html', 'text/html'], + ['js', 'text/javascript'], + ['css', 'text/css'], +]); + +// // TODO figure out which script is the entry +// function htmlWrapperForJS(script) { +// return ` +// +// Console output:
+//
+// +// `; +// } + +// listen here instead of attaching temporary 'message' event listeners to self +let messageProxy = new EventTarget(); + +self.addEventListener('message', (evt) => { + // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2339 - Property 'id' does not exist on type 'MessageEventSource'. + let parentId = evt.source.id; + let {type, data, id} = evt.data; + if (type === 'setFS') { + // called by worker + // @ts-expect-error - TS2531 - Object is possibly 'null'. + evt.source.postMessage({id}); + pages.set(parentId, data); + } else if (type === 'getID') { + // @ts-expect-error - TS2531 - Object is possibly 'null'. + evt.source.postMessage({id, data: parentId}); + } else if (type === 'hmrUpdate') { + // called by worker + // @ts-expect-error - TS2345 - Argument of type 'MessageEventSource | null' is not assignable to parameter of type 'MessagePort'. + parentPorts.set(parentId, evt.source); + let clientId = parentToIframe.get(parentId); + let send = + // @ts-expect-error - TS7005 - Variable 'lastHMRStream' implicitly has an 'any' type. + (clientId != null ? sendToIFrame.get(clientId) : null) ?? lastHMRStream; + send?.(data); + // @ts-expect-error - TS2531 - Object is possibly 'null'. + evt.source.postMessage({id}); + } else { + let wrapper = new Event(evt.type); + // @ts-expect-error - TS2339 - Property 'data' does not exist on type 'Event'. + wrapper.data = evt.data; + messageProxy.dispatchEvent(wrapper); + } +}); + +let encodeUTF8 = new TextEncoder(); + +self.addEventListener('fetch', (evt) => { + // @ts-expect-error - TS2339 - Property 'request' does not exist on type 'Event'. + let url = new URL(evt.request.url); + // @ts-expect-error - TS2339 - Property 'clientId' does not exist on type 'Event'. + let {clientId} = evt; + let parentId; + if (!clientId && url.searchParams.has('parentId')) { + // @ts-expect-error - TS2339 - Property 'resultingClientId' does not exist on type 'Event'. | TS2339 - Property 'targetClientId' does not exist on type 'Event'. + clientId = evt.resultingClientId ?? evt.targetClientId; + parentId = nullthrows(url.searchParams.get('parentId')); + parentToIframe.set(parentId, clientId); + iframeToParent.set(clientId, parentId); + } else { + // @ts-expect-error - TS2339 - Property 'clientId' does not exist on type 'Event'. + parentId = iframeToParent.get(evt.clientId); + } + if (parentId == null && isSafari) { + parentId = [...pages.keys()].slice(-1)[0]; + } + + if (parentId != null) { + if ( + // @ts-expect-error - TS2339 - Property 'request' does not exist on type 'Event'. + evt.request.headers.get('Accept') === 'text/event-stream' && + url.pathname === '/__parcel_hmr' + ) { + let stream = new ReadableStream({ + start: (controller) => { + // @ts-expect-error - TS7006 - Parameter 'data' implicitly has an 'any' type. + let cb = (data) => { + let chunk = `data: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encodeUTF8.encode(chunk)); + }; + sendToIFrame.set(clientId, cb); + lastHMRStream = cb; + }, + }); + + // @ts-expect-error - TS2339 - Property 'respondWith' does not exist on type 'Event'. + evt.respondWith( + new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Transfer-Encoding': 'chunked', + Connection: 'keep-alive', + ...SECURITY_HEADERS, + }, + }), + ); + } else if (url.pathname.startsWith('/__parcel_hmr/')) { + // @ts-expect-error - TS2339 - Property 'respondWith' does not exist on type 'Event'. + evt.respondWith( + (async () => { + let port = parentId != null ? parentPorts.get(parentId) : null; + + if (port == null) { + return new Response(null, {status: 500}); + } + + // @ts-expect-error - TS2488 - Type 'never' must have a '[Symbol.iterator]()' method that returns an iterator. | TS2554 - Expected 4 arguments, but got 3. + let [type, content] = await sendMsg( + port, + 'hmrAssetSource', + url.pathname.slice('/__parcel_hmr/'.length), + ); + return new Response(content, { + headers: { + 'Content-Type': + (MIME.get(type) ?? 'application/octet-stream') + + '; charset=utf-8', + 'Cache-Control': 'no-store', + ...SECURITY_HEADERS, + }, + }); + })(), + ); + } else if (url.pathname.startsWith('/__repl_dist/')) { + let filename = url.pathname.slice('/__repl_dist/'.length); + let file = pages.get(parentId)?.[filename]; + if (file == null) { + console.error('requested missing file', parentId, filename, pages); + } + + // @ts-expect-error - TS2339 - Property 'respondWith' does not exist on type 'Event'. + evt.respondWith( + new Response(file, { + headers: { + 'Content-Type': + (MIME.get(extname(filename)) ?? 'application/octet-stream') + + '; charset=utf-8', + 'Cache-Control': 'no-store', + ...SECURITY_HEADERS, + }, + }), + ); + } + } +}); + +function extname(filename: string) { + return filename.slice(filename.lastIndexOf('.') + 1); +} + +// @ts-expect-error - TS7006 - Parameter 'map' implicitly has an 'any' type. +function removeNonExistingKeys(existing: Set, map) { + for (let id of map.keys()) { + if (!existing.has(id)) { + map.delete(id); + } + } +} +setInterval(async () => { + let existingClients = new Set( + // @ts-expect-error - TS2339 - Property 'clients' does not exist on type 'Window & typeof globalThis'. | TS7006 - Parameter 'c' implicitly has an 'any' type. + (await self.clients.matchAll()).map((c) => c.id), + ); + + // @ts-expect-error - TS2345 - Argument of type 'Set' is not assignable to parameter of type 'Set'. + removeNonExistingKeys(existingClients, pages); + // @ts-expect-error - TS2345 - Argument of type 'Set' is not assignable to parameter of type 'Set'. + removeNonExistingKeys(existingClients, sendToIFrame); + // @ts-expect-error - TS2345 - Argument of type 'Set' is not assignable to parameter of type 'Set'. + removeNonExistingKeys(existingClients, parentToIframe); + // @ts-expect-error - TS2345 - Argument of type 'Set' is not assignable to parameter of type 'Set'. + removeNonExistingKeys(existingClients, iframeToParent); +}, 20000); + +function sendMsg( + target: MessagePort, + type: string, + data: string, + transfer: undefined, +) { + let id = uuidv4(); + return new Promise((res: (result: Promise) => void) => { + let handler = (evt: MessageEvent) => { + if (evt.data.id === id) { + // @ts-expect-error - TS2345 - Argument of type '(evt: MessageEvent) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject | null'. + messageProxy.removeEventListener('message', handler); + res(evt.data.data); + } + }; + // @ts-expect-error - TS2345 - Argument of type '(evt: MessageEvent) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject | null'. + messageProxy.addEventListener('message', handler); + target.postMessage({type, data, id}, transfer); + }); +} +function uuidv4() { + return (String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace( + /[018]/g, + // $FlowFixMe + // @ts-expect-error - TS2769 - No overload matches this call. + (c: number) => + ( + c ^ + // $FlowFixMe + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) + ).toString(16), + ); +} diff --git a/packages/dev/repl/src/utils/assets.js b/packages/dev/repl/src/utils/assets.js deleted file mode 100644 index a0a93d750..000000000 --- a/packages/dev/repl/src/utils/assets.js +++ /dev/null @@ -1,694 +0,0 @@ -// @flow -import path from 'path'; -import nullthrows from 'nullthrows'; -import type {REPLOptions} from './'; - -export type CodeMirrorDiagnostic = {| - from: number, - to: number, - severity: 'info' | 'warning' | 'error', - source: string, - message: string, - stack: ?string, -|}; - -export function join(a: string, ...b: Array): string { - return path.join(a || '/', ...b); -} - -export type File = {| - value: string, - isEntry?: boolean, -|}; -export type FSMap = Map; -export type FSList = Array<[string, File]>; - -export class FS implements Iterable<[string, File | FSMap]> { - /*:: @@iterator(): Iterator<[string, File | FSMap]> { - // $FlowFixMe - return {}; - } */ - - files: FSMap; - constructor(init: ?FSMap) { - this.files = init ?? new Map(); - } - - has(path: string): boolean { - return this.get(path) != null; - } - - get(path: string): ?File { - let parts = path.slice(1).split('/'); - let f = this.files; - for (let p of parts) { - // $FlowFixMe - f = f?.get(p); - } - // $FlowFixMe - return f; - } - - list(files: FSMap = this.files, prefix: string = ''): Map { - let result = []; - for (let [name, data] of files) { - let p = join(prefix, name); - if (data instanceof Map) { - result.push(...this.list(data, p)); - } else { - result.push([p, data]); - } - } - return new Map(result); - } - - move(from: string, to: string): FS { - let data = nullthrows(this.get(from)); - return this.delete(from).set(to, data); - } - - delete(path: string): FS { - let parts = path.slice(1).split('/'); - // $FlowFixMe - let result = new Map(this.files); - - let f = result; - for (let p of parts.slice(0, -1)) { - let copy = new Map(f.get(p) ?? []); - f.set(p, copy); - f = copy; - } - f.delete(parts[parts.length - 1]); - return new FS(result); - } - - set(path: string, value: FSMap | File): FS { - let parts = path.slice(1).split('/'); - // $FlowFixMe - let result = new Map(this.files); - - let f = result; - for (let p of parts.slice(0, -1)) { - // $FlowFixMe - let copy = new Map(f.get(p) ?? []); - f.set(p, copy); - f = copy; - } - f.set(parts[parts.length - 1], value); - return new FS(result); - } - - setMerge(path: string, value: $Shape): FS { - let data = nullthrows(this.get(path)); - return this.set(path, {...data, ...value}); - } - - // $FlowFixMe - [Symbol.iterator]() { - return this.files[Symbol.iterator](); - } - - toJSON(): Array<[string, File]> { - return [...this.list()]; - } - - static fromJSON(obj: Object): FS { - let fs = new FS(); - for (let [name, file] of obj) { - fs = fs.set(name, file); - } - return fs; - } -} - -const HMR_OPTIONS: $Shape = { - mode: 'development', - hmr: true, - scopeHoist: false, - sourceMaps: true, -}; - -export const ASSET_PRESETS: Map< - string, - {|options?: $Shape, fs: FSMap|}, -> = new Map([ - [ - 'Javascript', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - { - value: `import {Thing, x} from "./other.js";\nnew Thing().run();`, - isEntry: true, - }, - ], - [ - 'other.js', - { - value: `class Thing {\n run() {\n console.log("Test");\n } \n}\n\nconst x = 123;\nexport {Thing, x};`, - }, - ], - ]), - ], - ]), - }, - ], - [ - 'Flow', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - { - value: `// @flow\nfunction foo(n: number): number {\n\treturn n * n;\n}\n\nfoo(2);`, - isEntry: true, - }, - ], - ]), - ], - [ - 'package.json', - { - value: `{\n "devDependencies": {\n "flow-bin": "*"\n }\n}`, - isEntry: true, - }, - ], - ]), - }, - ], - // Babel: [ - // { - // name: 'src/index.js', - // content: `class Point { - // constructor(x, y) { - // this.x = x; - // this.y = y; - // } - // toString() { - // return \`(\${this.x}, \${this.y})\`; - // } - // } - - // console.log(new Point(1,2).toString()); - // `, - // isEntry: true, - // }, - // { - // name: '.babelrc', - // content: `{ "presets": [["@babel/env", {"loose": true}]] }`, - // }, - // // { - // // name: 'src/package.json', - // // content: `{\n "devDependencies": {\n "@babel/core": "^7.3.4",\n "@babel/preset-env": "^7.3.4"\n }\n}`, - // // }, - // ], - [ - 'Basic Page', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.html', - { - value: ` - - - - Link - -`, - isEntry: true, - }, - ], - [ - 'index.js', - { - value: `function func(){ - return "Hello World!"; -} -document.body.append(document.createTextNode(func()))`, - }, - ], - [ - 'style.css', - { - value: `body {\n color: red;\n}`, - }, - ], - [ - 'other.html', - { - value: 'This is a different page', - }, - ], - ]), - ], - // [ - // '.htmlnanorc', - // { - // value: `{\n minifySvg: false\n}`, - // }, - // ], - // [ - // 'cssnano.config.js', - // { - // value: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, - // }, - // ], - ]), - }, - ], - [ - 'JSON', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - { - value: "import x from './test.json';\nconsole.log(x);", - isEntry: true, - }, - ], - ['test.json', {value: '{a: 2, b: 3}'}], - ]), - ], - ]), - }, - ], - [ - 'Symbol Propagation', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - { - value: "import {a} from './lib.js';\nconsole.log(a);", - isEntry: true, - }, - ], - [ - 'lib.js', - {value: 'export * from "./lib1.js";\nexport * from "./lib2.js";'}, - ], - [ - 'lib1.js', - {value: 'console.log("Hello 1");\n\nexport const a = 1;'}, - ], - [ - 'lib2.js', - {value: 'console.log("Hello 2");\n\nexport const b = 2;'}, - ], - [ - 'package.json', - {value: JSON.stringify({sideEffects: ['index.js']}, null, 4)}, - ], - ]), - ], - ]), - }, - ], - [ - 'Dynamic Import', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - { - value: `import("./async.js").then(({a}) => console.log(a))`, - isEntry: true, - }, - ], - ['async.js', {value: 'export const a = 1;\nexport const b = 2;'}], - ]), - ], - ]), - }, - ], - [ - 'Envfile', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.js', - {value: 'console.log(process.env.SOMETHING);', isEntry: true}, - ], - ]), - ], - ['.env', {value: 'SOMETHING=124'}], - ]), - }, - ], - [ - 'Typescript', - { - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.ts', - { - value: `function greeter(person: string) { - return "Hello, " + person; -} - -let user = "Jane User"; - -document.body.innerHTML = greeter(user);`, - isEntry: true, - }, - ], - ]), - ], - ]), - }, - ], - // .parcelrc: [ - // { - // name: 'src/index.js', - // content: `const x = 1;\nconsole.log(x);`, - // isEntry: true, - // }, - // { - // name: '.parcelrc', - // content: JSON.stringify( - // { - // extends: '@atlaspack/config-repl', - // optimizers: { - // '*.js': [], - // }, - // }, - // null, - // 4, - // ), - // }, - // ], - [ - 'HMR', - { - options: HMR_OPTIONS, - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.html', - { - isEntry: true, - value: ` -
- -`, - }, - ], - [ - 'index.js', - { - value: `let counter = 0; - -if (module.hot) { - module.hot.dispose(function (data) { - data.counter = counter; - }); - - module.hot.accept(function () { - counter = module.hot.data.counter; - render(); - }); -} - -let btn = document.querySelector("button"); -btn.onclick = (e) => { - counter++; - render(); -} -function render(){ - btn.innerText = \`Update (\${counter})\`; -} -`, - }, - ], - ]), - ], - ]), - }, - ], - - [ - 'React (Fast Refresh)', - { - options: HMR_OPTIONS, - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.html', - { - isEntry: true, - value: ` -
-`, - }, - ], - [ - 'index.jsx', - { - value: `import * as React from "react"; -import { createRoot } from 'react-dom/client'; -import {App} from './App.jsx'; -let root = createRoot(document.querySelector("main")); -root.render();`, - }, - ], - [ - 'App.jsx', - { - value: `import * as React from "react"; - -export function App() { - let [counter, setCounter] = React.useState(0); - if (counter === 10) throw new Error("Too high!"); - return ( -
-
Change me!
- -
- ); -}`, - }, - ], - ]), - ], - [ - 'package.json', - { - value: JSON.stringify( - { - name: 'repl', - version: '0.0.0', - engines: { - browsers: 'since 2019', - }, - targets: { - app: {}, - }, - dependencies: { - react: '*', - 'react-dom': '*', - 'react-refresh': '^0.9.0', - }, - }, - null, - 4, - ), - }, - ], - ]), - }, - ], - - [ - 'React Spectrum', - { - options: HMR_OPTIONS, - fs: new Map([ - [ - 'src', - new Map([ - [ - 'index.html', - { - isEntry: true, - value: ` -
-`, - }, - ], - [ - 'index.jsx', - { - value: `import * as React from "react"; -import { createRoot } from 'react-dom/client'; -import {App} from './App.jsx'; -let root = createRoot(document.querySelector("main")); -root.render();`, - }, - ], - [ - 'App.jsx', - { - value: `import * as React from "react"; -import { - Provider, - Form, - TextField, - ActionButton, - AlertDialog, - DialogTrigger, - defaultTheme -} from "@adobe/react-spectrum"; -export function App() { - let [name, setName] = React.useState(""); - let [email, setEmail] = React.useState(""); - return ( - -
- - - - Save - - Hello {name}, is this really your email address: {email}? - - - -
- ); -} - `, - }, - ], - ]), - ], - [ - 'package.json', - { - value: JSON.stringify( - { - name: 'repl', - version: '0.0.0', - engines: { - browsers: 'since 2019', - }, - targets: { - app: {}, - }, - dependencies: { - '@adobe/react-spectrum': '*', - react: '*', - 'react-dom': '*', - 'react-refresh': '^0.9.0', - }, - }, - null, - 4, - ), - }, - ], - ]), - }, - ], - ['Three.js Benchmark', {fs: new Map()}], - // Markdown: [ - // { - // name: 'src/Article.md', - // content: '# My Title\n\nHello, ...\n\n```js\nconsole.log("test");\n```\n', - // isEntry: true, - // }, - // { - // name: '.htmlnanorc', - // content: `{\n minifySvg: false\n}`, - // }, - // ], - // SCSS: [ - // { - // name: 'src/style.scss', - // content: `$colorRed: red; - // #header { - // margin: 0; - // border: 1px solid $colorRed; - // p { - // color: $colorRed; - // font: { - // size: 12px; - // weight: bold; - // } - // } - // a { - // text-decoration: none; - // } - // }`, - // isEntry: true, - // }, - // { - // name: 'cssnano.config.js', - // content: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, - // }, - // ], - // LESS: [ - // { - // name: 'src/style.less', - // content: `@some-color: #143352; - - // #header { - // background-color: @some-color; - // } - // h2 { - // color: @some-color; - // }`, - // isEntry: true, - // }, - // { - // name: 'cssnano.config.js', - // content: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, - // }, - // ], - // }; -]); diff --git a/packages/dev/repl/src/utils/assets.ts b/packages/dev/repl/src/utils/assets.ts new file mode 100644 index 000000000..a36a9a9b8 --- /dev/null +++ b/packages/dev/repl/src/utils/assets.ts @@ -0,0 +1,700 @@ +import path from 'path'; +import nullthrows from 'nullthrows'; +import type {REPLOptions} from './'; + +export type CodeMirrorDiagnostic = { + from: number; + to: number; + severity: 'info' | 'warning' | 'error'; + source: string; + message: string; + stack: string | null | undefined; +}; + +export function join(a: string, ...b: Array): string { + return path.join(a || '/', ...b); +} + +export type File = { + value: string; + isEntry?: boolean; +}; +export type FSMap = Map; +export type FSList = Array<[string, File]>; + +export class FS implements Iterable<[string, File | FSMap]> { + /*:: @@iterator(): Iterator<[string, File | FSMap]> { + // $FlowFixMe + return {}; + } */ + + files: FSMap; + constructor(init?: FSMap | null) { + this.files = init ?? new Map(); + } + + has(path: string): boolean { + return this.get(path) != null; + } + + get(path: string): File | null | undefined { + let parts = path.slice(1).split('/'); + let f = this.files; + for (let p of parts) { + // @ts-expect-error - TS2322 - Type 'File | FSMap | undefined' is not assignable to type 'FSMap'. + f = f?.get(p); + } + // @ts-expect-error - TS2741 - Property 'value' is missing in type 'Map' but required in type 'File'. + return f; + } + + list(files: FSMap = this.files, prefix: string = ''): Map { + let result: Array | [string, File]> = []; + for (let [name, data] of files) { + let p = join(prefix, name); + if (data instanceof Map) { + result.push(...this.list(data, p)); + } else { + result.push([p, data]); + } + } + // @ts-expect-error - TS2769 - No overload matches this call. + return new Map(result); + } + + move(from: string, to: string): FS { + let data = nullthrows(this.get(from)); + return this.delete(from).set(to, data); + } + + delete(path: string): FS { + let parts = path.slice(1).split('/'); + let result = new Map(this.files); + + let f = result; + for (let p of parts.slice(0, -1)) { + // @ts-expect-error - TS2769 - No overload matches this call. + let copy = new Map(f.get(p) ?? []); + f.set(p, copy); + f = copy; + } + f.delete(parts[parts.length - 1]); + return new FS(result); + } + + set(path: string, value: FSMap | File): FS { + let parts = path.slice(1).split('/'); + let result = new Map(this.files); + + let f = result; + for (let p of parts.slice(0, -1)) { + // @ts-expect-error - TS2769 - No overload matches this call. + let copy = new Map(f.get(p) ?? []); + f.set(p, copy); + f = copy; + } + f.set(parts[parts.length - 1], value); + return new FS(result); + } + + setMerge(path: string, value: Partial): FS { + let data = nullthrows(this.get(path)); + return this.set(path, {...data, ...value}); + } + + // $FlowFixMe + [Symbol.iterator]() { + return this.files[Symbol.iterator](); + } + + toJSON(): Array<[string, File]> { + return [...this.list()]; + } + + static fromJSON(obj: any): FS { + let fs = new FS(); + for (let [name, file] of obj) { + fs = fs.set(name, file); + } + return fs; + } +} + +const HMR_OPTIONS: Partial = { + mode: 'development', + hmr: true, + scopeHoist: false, + sourceMaps: true, +}; + +export const ASSET_PRESETS: Map< + string, + { + options?: Partial; + fs: FSMap; + } +> = new Map([ + [ + 'Javascript', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + { + value: `import {Thing, x} from "./other.js";\nnew Thing().run();`, + isEntry: true, + }, + ], + [ + 'other.js', + { + value: `class Thing {\n run() {\n console.log("Test");\n } \n}\n\nconst x = 123;\nexport {Thing, x};`, + }, + ], + ]), + ], + ]), + }, + ], + [ + 'Flow', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + { + value: `// @flow\nfunction foo(n: number): number {\n\treturn n * n;\n}\n\nfoo(2);`, + isEntry: true, + }, + ], + ]), + ], + [ + 'package.json', + { + // @ts-expect-error - TS2769 - No overload matches this call. + value: `{\n "devDependencies": {\n "flow-bin": "*"\n }\n}`, + isEntry: true, + }, + ], + ]), + }, + ], + // Babel: [ + // { + // name: 'src/index.js', + // content: `class Point { + // constructor(x, y) { + // this.x = x; + // this.y = y; + // } + // toString() { + // return \`(\${this.x}, \${this.y})\`; + // } + // } + + // console.log(new Point(1,2).toString()); + // `, + // isEntry: true, + // }, + // { + // name: '.babelrc', + // content: `{ "presets": [["@babel/env", {"loose": true}]] }`, + // }, + // // { + // // name: 'src/package.json', + // // content: `{\n "devDependencies": {\n "@babel/core": "^7.3.4",\n "@babel/preset-env": "^7.3.4"\n }\n}`, + // // }, + // ], + [ + 'Basic Page', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.html', + { + value: ` + + + + Link + +`, + isEntry: true, + }, + ], + [ + 'index.js', + { + value: `function func(){ + return "Hello World!"; +} +document.body.append(document.createTextNode(func()))`, + }, + ], + [ + 'style.css', + { + value: `body {\n color: red;\n}`, + }, + ], + [ + 'other.html', + { + value: 'This is a different page', + }, + ], + ]), + ], + // [ + // '.htmlnanorc', + // { + // value: `{\n minifySvg: false\n}`, + // }, + // ], + // [ + // 'cssnano.config.js', + // { + // value: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, + // }, + // ], + ]), + }, + ], + [ + 'JSON', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + { + value: "import x from './test.json';\nconsole.log(x);", + isEntry: true, + }, + ], + ['test.json', {value: '{a: 2, b: 3}'}], + ]), + ], + ]), + }, + ], + [ + 'Symbol Propagation', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + { + value: "import {a} from './lib.js';\nconsole.log(a);", + isEntry: true, + }, + ], + [ + 'lib.js', + {value: 'export * from "./lib1.js";\nexport * from "./lib2.js";'}, + ], + [ + 'lib1.js', + {value: 'console.log("Hello 1");\n\nexport const a = 1;'}, + ], + [ + 'lib2.js', + {value: 'console.log("Hello 2");\n\nexport const b = 2;'}, + ], + [ + 'package.json', + {value: JSON.stringify({sideEffects: ['index.js']}, null, 4)}, + ], + ]), + ], + ]), + }, + ], + [ + 'Dynamic Import', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + { + value: `import("./async.js").then(({a}) => console.log(a))`, + isEntry: true, + }, + ], + ['async.js', {value: 'export const a = 1;\nexport const b = 2;'}], + ]), + ], + ]), + }, + ], + [ + 'Envfile', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.js', + {value: 'console.log(process.env.SOMETHING);', isEntry: true}, + ], + ]), + ], + // @ts-expect-error - TS2769 - No overload matches this call. + ['.env', {value: 'SOMETHING=124'}], + ]), + }, + ], + [ + 'Typescript', + { + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.ts', + { + value: `function greeter(person: string) { + return "Hello, " + person; +} + +let user = "Jane User"; + +document.body.innerHTML = greeter(user);`, + isEntry: true, + }, + ], + ]), + ], + ]), + }, + ], + // .parcelrc: [ + // { + // name: 'src/index.js', + // content: `const x = 1;\nconsole.log(x);`, + // isEntry: true, + // }, + // { + // name: '.parcelrc', + // content: JSON.stringify( + // { + // extends: '@atlaspack/config-repl', + // optimizers: { + // '*.js': [], + // }, + // }, + // null, + // 4, + // ), + // }, + // ], + [ + 'HMR', + { + options: HMR_OPTIONS, + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.html', + { + isEntry: true, + value: ` +
+ +`, + }, + ], + [ + 'index.js', + { + value: `let counter = 0; + +if (module.hot) { + module.hot.dispose(function (data) { + data.counter = counter; + }); + + module.hot.accept(function () { + counter = module.hot.data.counter; + render(); + }); +} + +let btn = document.querySelector("button"); +btn.onclick = (e) => { + counter++; + render(); +} +function render(){ + btn.innerText = \`Update (\${counter})\`; +} +`, + }, + ], + ]), + ], + ]), + }, + ], + + [ + 'React (Fast Refresh)', + { + options: HMR_OPTIONS, + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.html', + { + isEntry: true, + value: ` +
+`, + }, + ], + [ + 'index.jsx', + { + value: `import * as React from "react"; +import { createRoot } from 'react-dom/client'; +import {App} from './App.jsx'; +let root = createRoot(document.querySelector("main")); +root.render();`, + }, + ], + [ + 'App.jsx', + { + value: `import * as React from "react"; + +export function App() { + let [counter, setCounter] = React.useState(0); + if (counter === 10) throw new Error("Too high!"); + return ( +
+
Change me!
+ +
+ ); +}`, + }, + ], + ]), + ], + [ + 'package.json', + { + // @ts-expect-error - TS2769 - No overload matches this call. + value: JSON.stringify( + { + name: 'repl', + version: '0.0.0', + engines: { + browsers: 'since 2019', + }, + targets: { + app: {}, + }, + dependencies: { + react: '*', + 'react-dom': '*', + 'react-refresh': '^0.9.0', + }, + }, + null, + 4, + ), + }, + ], + ]), + }, + ], + + [ + 'React Spectrum', + { + options: HMR_OPTIONS, + fs: new Map([ + [ + 'src', + new Map([ + [ + 'index.html', + { + isEntry: true, + value: ` +
+`, + }, + ], + [ + 'index.jsx', + { + value: `import * as React from "react"; +import { createRoot } from 'react-dom/client'; +import {App} from './App.jsx'; +let root = createRoot(document.querySelector("main")); +root.render();`, + }, + ], + [ + 'App.jsx', + { + value: `import * as React from "react"; +import { + Provider, + Form, + TextField, + ActionButton, + AlertDialog, + DialogTrigger, + defaultTheme +} from "@adobe/react-spectrum"; +export function App() { + let [name, setName] = React.useState(""); + let [email, setEmail] = React.useState(""); + return ( + +
+ + + + Save + + Hello {name}, is this really your email address: {email}? + + + +
+ ); +} + `, + }, + ], + ]), + ], + [ + 'package.json', + { + // @ts-expect-error - TS2769 - No overload matches this call. + value: JSON.stringify( + { + name: 'repl', + version: '0.0.0', + engines: { + browsers: 'since 2019', + }, + targets: { + app: {}, + }, + dependencies: { + '@adobe/react-spectrum': '*', + react: '*', + 'react-dom': '*', + 'react-refresh': '^0.9.0', + }, + }, + null, + 4, + ), + }, + ], + ]), + }, + ], + ['Three.js Benchmark', {fs: new Map()}], + // Markdown: [ + // { + // name: 'src/Article.md', + // content: '# My Title\n\nHello, ...\n\n```js\nconsole.log("test");\n```\n', + // isEntry: true, + // }, + // { + // name: '.htmlnanorc', + // content: `{\n minifySvg: false\n}`, + // }, + // ], + // SCSS: [ + // { + // name: 'src/style.scss', + // content: `$colorRed: red; + // #header { + // margin: 0; + // border: 1px solid $colorRed; + // p { + // color: $colorRed; + // font: { + // size: 12px; + // weight: bold; + // } + // } + // a { + // text-decoration: none; + // } + // }`, + // isEntry: true, + // }, + // { + // name: 'cssnano.config.js', + // content: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, + // }, + // ], + // LESS: [ + // { + // name: 'src/style.less', + // content: `@some-color: #143352; + + // #header { + // background-color: @some-color; + // } + // h2 { + // color: @some-color; + // }`, + // isEntry: true, + // }, + // { + // name: 'cssnano.config.js', + // content: `module.exports = {\n preset: [\n 'default',\n {\n svgo: false\n }\n ]\n}`, + // }, + // ], + // }; +]); diff --git a/packages/dev/repl/src/utils/index.js b/packages/dev/repl/src/utils/index.js deleted file mode 100644 index d1f227d66..000000000 --- a/packages/dev/repl/src/utils/index.js +++ /dev/null @@ -1,110 +0,0 @@ -// @flow -import JSZip from 'jszip'; -import {type FSMap} from './assets'; - -export * from './assets'; -export * from './options'; - -export function nthIndex(str: string, pat: string, n: number): number { - var length = str.length, - i = -1; - while (n-- && i++ < length) { - i = str.indexOf(pat, i); - if (i < 0) break; - } - return i; -} - -export const ctrlKey: string = navigator.platform.includes('Mac') - ? '⌘' - : 'Ctrl'; - -function downloadBlob(name: string, blob: Blob) { - const el = document.createElement('a'); - el.href = URL.createObjectURL(blob); - el.download = name; - el.click(); - setTimeout(() => URL.revokeObjectURL(el.href), 1000); -} -// function downloadBuffer(name: string, buf: Uint8Array, mime: string) { -// const blob = new Blob([buf], {type: mime}); -// downloadBlob(name, blob); -// } - -export async function downloadZIP(files: Map) { - let zip = new JSZip(); - - for (let [name, {value}] of files) { - zip.file(name, value); - } - - let blob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { - level: 5, - }, - }); - - downloadBlob('repl.zip', blob); -} - -export async function extractZIP(content: ArrayBuffer): Promise { - let zip = await JSZip.loadAsync(content); - - let files = ( - await Promise.all( - Object.entries(zip.files).map(async ([relativePath, zipEntry]) => { - // $FlowFixMe - if (!zipEntry.dir) { - // $FlowFixMe - return [relativePath, {value: await zipEntry.async('string')}]; - } - }), - ) - ).filter(Boolean); - - let result: FSMap = new Map(); - function get(p): FSMap { - let v = result; - for (let e of p) { - // $FlowFixMe - let c = v.get(e); - if (!c) { - c = new Map(); - // $FlowFixMe - v.set(e, c); - } - v = c; - } - // $FlowFixMe - return v; - } - for (let [p, data] of files) { - let pSplit = p.split('/'); - let folder = pSplit.slice(0, -1); - let file = pSplit[pSplit.length - 1]; - get(folder).set(file, data); - } - - return result; -} - -export function linkSourceMapVisualization( - bundle: string, - sourcemap: string, -): string { - let hash = Buffer.concat([ - Buffer.from(String(bundle.length)), - Buffer.from([0]), - Buffer.from(bundle), - Buffer.from(String(sourcemap.length)), - Buffer.from([0]), - Buffer.from(sourcemap), - ]); - - return ( - 'https://evanw.github.io/source-map-visualization/#' + - hash.toString('base64') - ); -} diff --git a/packages/dev/repl/src/utils/index.ts b/packages/dev/repl/src/utils/index.ts new file mode 100644 index 000000000..c9d9102c7 --- /dev/null +++ b/packages/dev/repl/src/utils/index.ts @@ -0,0 +1,115 @@ +import JSZip from 'jszip'; +import {FSMap} from './assets'; + +export * from './assets'; +export * from './options'; + +export function nthIndex(str: string, pat: string, n: number): number { + var length = str.length, + i = -1; + while (n-- && i++ < length) { + i = str.indexOf(pat, i); + if (i < 0) break; + } + return i; +} + +export const ctrlKey: string = navigator.platform.includes('Mac') + ? '⌘' + : 'Ctrl'; + +function downloadBlob(name: string, blob: Blob) { + const el = document.createElement('a'); + el.href = URL.createObjectURL(blob); + el.download = name; + el.click(); + setTimeout(() => URL.revokeObjectURL(el.href), 1000); +} +// function downloadBuffer(name: string, buf: Uint8Array, mime: string) { +// const blob = new Blob([buf], {type: mime}); +// downloadBlob(name, blob); +// } + +export async function downloadZIP( + files: Map< + string, + { + value: string; + } + >, +) { + let zip = new JSZip(); + + for (let [name, {value}] of files) { + zip.file(name, value); + } + + let blob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 5, + }, + }); + + downloadBlob('repl.zip', blob); +} + +export async function extractZIP(content: ArrayBuffer): Promise { + let zip = await JSZip.loadAsync(content); + + let files = ( + await Promise.all( + Object.entries(zip.files).map( + async ([relativePath, zipEntry]: [any, any]) => { + if (!zipEntry.dir) { + return [relativePath, {value: await zipEntry.async('string')}]; + } + }, + ), + ) + ).filter(Boolean); + + let result: FSMap = new Map(); + function get(p: Array): FSMap { + let v = result; + for (let e of p) { + let c = v.get(e); + if (!c) { + c = new Map(); + v.set(e, c); + } + // @ts-expect-error - TS2322 - Type 'File | FSMap' is not assignable to type 'FSMap'. + v = c; + } + return v; + } + // @ts-expect-error - TS2488 - Type 'any[] | undefined' must have a '[Symbol.iterator]()' method that returns an iterator. + for (let [p, data] of files) { + let pSplit = p.split('/'); + let folder = pSplit.slice(0, -1); + let file = pSplit[pSplit.length - 1]; + get(folder).set(file, data); + } + + return result; +} + +export function linkSourceMapVisualization( + bundle: string, + sourcemap: string, +): string { + let hash = Buffer.concat([ + Buffer.from(String(bundle.length)), + Buffer.from([0]), + Buffer.from(bundle), + Buffer.from(String(sourcemap.length)), + Buffer.from([0]), + Buffer.from(sourcemap), + ]); + + return ( + 'https://evanw.github.io/source-map-visualization/#' + + hash.toString('base64') + ); +} diff --git a/packages/dev/repl/src/utils/options.js b/packages/dev/repl/src/utils/options.js deleted file mode 100644 index 4837fb166..000000000 --- a/packages/dev/repl/src/utils/options.js +++ /dev/null @@ -1,58 +0,0 @@ -// @flow -import type {PackageJSON} from '@atlaspack/types'; - -export type REPLOptions = {| - entries: [], - minify: boolean, - scopeHoist: boolean, - sourceMaps: boolean, - publicUrl: string, - targetType: 'node' | 'browsers', - targetEnv: null | string, - outputFormat: null | 'esmodule' | 'commonjs' | 'global', - mode: 'production' | 'development', - hmr: boolean, - renderGraphs: boolean, - viewSourcemaps: boolean, // // unused - dependencies: Array<[string, string]>, - numWorkers: ?number, -|}; - -export function getDefaultTargetEnv( - type: $ElementType, -): string { - switch (type) { - case 'node': - return '12'; - case 'browsers': - return 'since 2019'; - default: - throw new Error(`Missing default target env for ${type}`); - } -} - -export function generatePackageJson(options: REPLOptions): string { - let app = {}; - if (options.outputFormat) { - app.outputFormat = options.outputFormat; - } - - let pkg: PackageJSON = { - name: 'repl', - version: '0.0.0', - engines: { - [(options.targetType: string)]: - options.targetEnv || getDefaultTargetEnv(options.targetType), - }, - targets: { - app, - }, - dependencies: Object.fromEntries( - options.dependencies - .filter(([a, b]) => a && b) - .sort(([a], [b]) => a.localeCompare(b)), - ), - }; - - return JSON.stringify(pkg, null, 2); -} diff --git a/packages/dev/repl/src/utils/options.ts b/packages/dev/repl/src/utils/options.ts new file mode 100644 index 000000000..b5b6974a4 --- /dev/null +++ b/packages/dev/repl/src/utils/options.ts @@ -0,0 +1,56 @@ +import type {PackageJSON} from '@atlaspack/types'; + +export type REPLOptions = { + entries: []; + minify: boolean; + scopeHoist: boolean; + sourceMaps: boolean; + publicUrl: string; + targetType: 'node' | 'browsers'; + targetEnv: null | string; + outputFormat: null | 'esmodule' | 'commonjs' | 'global'; + mode: 'production' | 'development'; + hmr: boolean; + renderGraphs: boolean; + viewSourcemaps: boolean; // // unused, + dependencies: Array<[string, string]>; + numWorkers: number | null | undefined; +}; + +export function getDefaultTargetEnv(type: REPLOptions['targetType']): string { + switch (type) { + case 'node': + return '12'; + case 'browsers': + return 'since 2019'; + default: + throw new Error(`Missing default target env for ${type}`); + } +} + +export function generatePackageJson(options: REPLOptions): string { + let app: Record = {}; + if (options.outputFormat) { + app.outputFormat = options.outputFormat; + } + + let pkg: PackageJSON = { + name: 'repl', + version: '0.0.0', + engines: { + [options.targetType as string]: + options.targetEnv || getDefaultTargetEnv(options.targetType), + }, + targets: { + app, + }, + dependencies: Object.fromEntries( + options.dependencies + .filter(([a, b]: [any, any]) => a && b) + // @ts-expect-error - TS2345 - Argument of type '([a]: [any], [b]: [any]) => any' is not assignable to parameter of type '(a: [string, string], b: [string, string]) => number'. + .sort(([a]: [any], [b]: [any]) => a.localeCompare(b)), + ), + }; + + return JSON.stringify(pkg, null, 2); +} diff --git a/packages/dev/repl/write-commit.js b/packages/dev/repl/write-commit.js deleted file mode 100644 index cb5ed4979..000000000 --- a/packages/dev/repl/write-commit.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow -const child_process = require('child_process'); -const path = require('path'); -const fs = require('fs'); - -let file = path.join(__dirname, 'commit'); - -let oldCommit = fs.existsSync(file) && fs.readFileSync(file, 'utf8').trim(); - -const newCommit = child_process - // .execSync('git merge-base v2 HEAD', {encoding: 'utf8'}) - .execSync('git rev-parse HEAD', {encoding: 'utf8'}) - .trim(); - -if (oldCommit !== newCommit) { - fs.writeFileSync(file, newCommit); -} diff --git a/packages/dev/repl/write-commit.ts b/packages/dev/repl/write-commit.ts new file mode 100644 index 000000000..c681890ae --- /dev/null +++ b/packages/dev/repl/write-commit.ts @@ -0,0 +1,16 @@ +const child_process = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +let file = path.join(__dirname, 'commit'); + +let oldCommit = fs.existsSync(file) && fs.readFileSync(file, 'utf8').trim(); + +const newCommit = child_process + // .execSync('git merge-base v2 HEAD', {encoding: 'utf8'}) + .execSync('git rev-parse HEAD', {encoding: 'utf8'}) + .trim(); + +if (oldCommit !== newCommit) { + fs.writeFileSync(file, newCommit); +} diff --git a/packages/examples/conditional-bundling/src/feature-disabled.ts b/packages/examples/conditional-bundling/src/feature-disabled.ts index d9cc8d2e2..5743a52ed 100644 --- a/packages/examples/conditional-bundling/src/feature-disabled.ts +++ b/packages/examples/conditional-bundling/src/feature-disabled.ts @@ -1,2 +1,3 @@ export default () => 'The feature is DISABLED'; +// @ts-expect-error - TS7006 - Parameter 'a' implicitly has an 'any' type. | TS7006 - Parameter 'b' implicitly has an 'any' type. export const add = (a, b) => a + b; diff --git a/packages/examples/conditional-bundling/src/feature-ui-disabled.tsx b/packages/examples/conditional-bundling/src/feature-ui-disabled.tsx index 271f7866e..b0a11857d 100644 --- a/packages/examples/conditional-bundling/src/feature-ui-disabled.tsx +++ b/packages/examples/conditional-bundling/src/feature-ui-disabled.tsx @@ -1,5 +1,6 @@ import React from "react"; export default function Component() { +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. return ; } diff --git a/packages/examples/conditional-bundling/src/feature-ui-enabled.tsx b/packages/examples/conditional-bundling/src/feature-ui-enabled.tsx index 13846275a..5bc3a1e6a 100644 --- a/packages/examples/conditional-bundling/src/feature-ui-enabled.tsx +++ b/packages/examples/conditional-bundling/src/feature-ui-enabled.tsx @@ -2,5 +2,6 @@ import Button from '@atlaskit/button/new'; import React from 'react'; export default function Component() { +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. return ; } diff --git a/packages/examples/conditional-bundling/src/index.tsx b/packages/examples/conditional-bundling/src/index.tsx index 7a6b699cc..ff7cf316e 100644 --- a/packages/examples/conditional-bundling/src/index.tsx +++ b/packages/examples/conditional-bundling/src/index.tsx @@ -8,15 +8,20 @@ const Feature = importCond< typeof import('./feature-disabled') >('my.feature', './feature-enabled', './feature-disabled'); const FeatureWithUI = importCond< +// @ts-expect-error - TS6142 - Module './feature-ui-enabled' was resolved to '/home/ubuntu/parcel/packages/examples/conditional-bundling/src/feature-ui-enabled.tsx', but '--jsx' is not set. typeof import('./feature-ui-enabled'), +// @ts-expect-error - TS6142 - Module './feature-ui-disabled' was resolved to '/home/ubuntu/parcel/packages/examples/conditional-bundling/src/feature-ui-disabled.tsx', but '--jsx' is not set. typeof import('./feature-ui-disabled') >('feature.ui', './feature-ui-enabled', './feature-ui-disabled'); +// @ts-expect-error - TS6142 - Module './lazy-component' was resolved to '/home/ubuntu/parcel/packages/examples/conditional-bundling/src/lazy-component.tsx', but '--jsx' is not set. const LazyComponent = lazy(() => import('./lazy-component')); function LazyComponentContainer() { return ( +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. | TS17004 - Cannot use JSX unless the '--jsx' flag is provided. Loading...

}> +{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */}
); @@ -28,16 +33,23 @@ const App = () => { console.log('FeatureWithUI', FeatureWithUI); console.log('Feature', Feature); return ( +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided.
+{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */}

Hello from React

+{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */} +{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */}

Conditional Feature: {Feature()}

+{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */} +{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */} {showLazyComponent ? : null}
); }; +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. ReactDOM.render(, document.getElementById('container')); diff --git a/packages/examples/conditional-bundling/src/lazy-component.tsx b/packages/examples/conditional-bundling/src/lazy-component.tsx index 3fe033598..2736cd1ae 100644 --- a/packages/examples/conditional-bundling/src/lazy-component.tsx +++ b/packages/examples/conditional-bundling/src/lazy-component.tsx @@ -3,7 +3,9 @@ import Button from '@atlaskit/button'; export default function LazyComponent() { return ( +// @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided.

+{ /* @ts-expect-error - TS17004 - Cannot use JSX unless the '--jsx' flag is provided. */} This is a lazy component. It has a button.

); diff --git a/packages/examples/conditional-bundling/src/regular-dynamic-import.ts b/packages/examples/conditional-bundling/src/regular-dynamic-import.ts index d9f7f6f74..97fb7fcc9 100644 --- a/packages/examples/conditional-bundling/src/regular-dynamic-import.ts +++ b/packages/examples/conditional-bundling/src/regular-dynamic-import.ts @@ -7,5 +7,6 @@ export const DynamicExportWithCondition = () => { 'feature.async.condition', './async-feature-enabled.ts', './async-feature-disabled.ts', + // @ts-expect-error - TS2339 - Property 'Feature' does not exist on type 'ConditionalImport'. ).Feature(); }; diff --git a/packages/examples/typechecking/src/index.ts b/packages/examples/typechecking/src/index.ts index ba38e0975..93e298626 100644 --- a/packages/examples/typechecking/src/index.ts +++ b/packages/examples/typechecking/src/index.ts @@ -3,5 +3,6 @@ type Params = { }; export default function test(params: Params) { + // @ts-expect-error - TS2339 - Property 'world' does not exist on type 'Params'. return params.world; } diff --git a/packages/migrations/parcel-to-atlaspack/src/cli.ts b/packages/migrations/parcel-to-atlaspack/src/cli.ts index 6e4cd14ac..01a33ec01 100644 --- a/packages/migrations/parcel-to-atlaspack/src/cli.ts +++ b/packages/migrations/parcel-to-atlaspack/src/cli.ts @@ -1,5 +1,6 @@ import {Command} from 'commander'; +// @ts-expect-error - TS2732 - Cannot find module '../package.json'. Consider using '--resolveJsonModule' to import module with '.json' extension. import packageJson from '../package.json'; import {migratePackageJson} from './migrations/migrate-package-json'; diff --git a/packages/namers/default/package.json b/packages/namers/default/package.json index 494a5bd6d..a0f319c6f 100644 --- a/packages/namers/default/package.json +++ b/packages/namers/default/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/DefaultNamer.js", - "source": "src/DefaultNamer.js", + "types": "src/DefaultNamer.ts", + "source": "src/DefaultNamer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/namers/default/src/DefaultNamer.js b/packages/namers/default/src/DefaultNamer.js deleted file mode 100644 index cc89b11ab..000000000 --- a/packages/namers/default/src/DefaultNamer.js +++ /dev/null @@ -1,145 +0,0 @@ -// @flow strict-local - -import type {Bundle, FilePath} from '@atlaspack/types'; - -import {Namer} from '@atlaspack/plugin'; -import ThrowableDiagnostic, { - convertSourceLocationToHighlight, - md, -} from '@atlaspack/diagnostic'; -import assert from 'assert'; -import path from 'path'; -import nullthrows from 'nullthrows'; - -const COMMON_NAMES = new Set(['index', 'src', 'lib']); -const ALLOWED_EXTENSIONS = { - js: ['js', 'mjs', 'cjs'], -}; - -export default (new Namer({ - name({bundle, bundleGraph}) { - let bundleGroup = bundleGraph.getBundleGroupsContainingBundle(bundle)[0]; - let bundleGroupBundles = bundleGraph.getBundlesInBundleGroup(bundleGroup, { - includeInline: true, - }); - let isEntry = bundleGraph.isEntryBundleGroup(bundleGroup); - - if (bundle.needsStableName) { - let entryBundlesOfType = bundleGroupBundles.filter( - b => b.needsStableName && b.type === bundle.type, - ); - assert( - entryBundlesOfType.length === 1, - // Otherwise, we'd end up naming two bundles the same thing. - `Bundle group cannot have more than one entry bundle of the same type. The offending bundle type is ${entryBundlesOfType[0].type}`, - ); - } - - let mainBundle = nullthrows( - bundleGroupBundles.find(b => - b.getEntryAssets().some(a => a.id === bundleGroup.entryAssetId), - ), - ); - - if ( - bundle.id === mainBundle.id && - isEntry && - bundle.target && - bundle.target.distEntry != null - ) { - let loc = bundle.target.loc; - let distEntry = bundle.target.distEntry; - let distExtension = path.extname(bundle.target.distEntry).slice(1); - let allowedExtensions = ALLOWED_EXTENSIONS[bundle.type] || [bundle.type]; - if (!allowedExtensions.includes(distExtension) && loc) { - let fullName = path.relative( - path.dirname(loc.filePath), - path.join(bundle.target.distDir, distEntry), - ); - let err = new ThrowableDiagnostic({ - diagnostic: { - message: md`Target "${bundle.target.name}" declares an output file path of "${fullName}" which does not match the compiled bundle type "${bundle.type}".`, - codeFrames: [ - { - filePath: loc.filePath, - codeHighlights: [ - convertSourceLocationToHighlight( - loc, - md`Did you mean "${ - fullName.slice(0, -path.extname(fullName).length) + - '.' + - bundle.type - }"?`, - ), - ], - }, - ], - hints: [ - `Try changing the file extension of "${ - bundle.target.name - }" in ${path.relative(process.cwd(), loc.filePath)}.`, - ], - }, - }); - throw err; - } - - return bundle.target.distEntry; - } - - // Base split bundle names on the first bundle in their group. - // e.g. if `index.js` imports `foo.css`, the css bundle should be called - // `index.css`. - let name = nameFromContent( - mainBundle, - isEntry, - bundleGroup.entryAssetId, - bundleGraph.getEntryRoot(bundle.target), - ); - if (!bundle.needsStableName) { - name += '.' + bundle.hashReference; - } - - return name + '.' + bundle.type; - }, -}): Namer); - -function nameFromContent( - bundle: Bundle, - isEntry: boolean, - entryAssetId: string, - entryRoot: FilePath, -): string { - let entryFilePath = nullthrows( - bundle.getEntryAssets().find(a => a.id === entryAssetId), - ).filePath; - let name = basenameWithoutExtension(entryFilePath); - - // If this is an entry bundle, use the original relative path. - if (bundle.needsStableName) { - // Match name of target entry if possible, but with a different extension. - if (isEntry && bundle.target.distEntry != null) { - return basenameWithoutExtension(bundle.target.distEntry); - } - - return path - .join(path.relative(entryRoot, path.dirname(entryFilePath)), name) - .replace(/\.\.(\/|\\)/g, 'up_$1'); - } else { - // If this is an index file or common directory name, use the parent - // directory name instead, which is probably more descriptive. - while (COMMON_NAMES.has(name)) { - entryFilePath = path.dirname(entryFilePath); - name = path.basename(entryFilePath); - if (name.startsWith('.')) { - name = name.replace('.', ''); - } - } - - return name || 'bundle'; - } -} - -function basenameWithoutExtension(file) { - return path.basename(file, path.extname(file)); -} diff --git a/packages/namers/default/src/DefaultNamer.ts b/packages/namers/default/src/DefaultNamer.ts new file mode 100644 index 000000000..6cf105aa6 --- /dev/null +++ b/packages/namers/default/src/DefaultNamer.ts @@ -0,0 +1,146 @@ +import type {Bundle, FilePath} from '@atlaspack/types'; + +import {Namer} from '@atlaspack/plugin'; +import ThrowableDiagnostic, { + convertSourceLocationToHighlight, + md, +} from '@atlaspack/diagnostic'; +import assert from 'assert'; +import path from 'path'; +import nullthrows from 'nullthrows'; + +const COMMON_NAMES = new Set(['index', 'src', 'lib']); +const ALLOWED_EXTENSIONS = { + js: ['js', 'mjs', 'cjs'], +} as const; + +export default new Namer({ + name({bundle, bundleGraph}) { + let bundleGroup = bundleGraph.getBundleGroupsContainingBundle(bundle)[0]; + let bundleGroupBundles = bundleGraph.getBundlesInBundleGroup(bundleGroup, { + includeInline: true, + }); + let isEntry = bundleGraph.isEntryBundleGroup(bundleGroup); + + if (bundle.needsStableName) { + let entryBundlesOfType = bundleGroupBundles.filter( + (b) => b.needsStableName && b.type === bundle.type, + ); + assert( + entryBundlesOfType.length === 1, + // Otherwise, we'd end up naming two bundles the same thing. + `Bundle group cannot have more than one entry bundle of the same type. The offending bundle type is ${entryBundlesOfType[0].type}`, + ); + } + + let mainBundle = nullthrows( + bundleGroupBundles.find((b) => + b.getEntryAssets().some((a) => a.id === bundleGroup.entryAssetId), + ), + ); + + if ( + bundle.id === mainBundle.id && + isEntry && + bundle.target && + bundle.target.distEntry != null + ) { + let loc = bundle.target.loc; + let distEntry = bundle.target.distEntry; + let distExtension = path.extname(bundle.target.distEntry).slice(1); + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly js: readonly ["js", "mjs", "cjs"]; }'. + let allowedExtensions = ALLOWED_EXTENSIONS[bundle.type] || [bundle.type]; + if (!allowedExtensions.includes(distExtension) && loc) { + let fullName = path.relative( + path.dirname(loc.filePath), + path.join(bundle.target.distDir, distEntry), + ); + let err = new ThrowableDiagnostic({ + diagnostic: { + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message: md`Target "${bundle.target.name}" declares an output file path of "${fullName}" which does not match the compiled bundle type "${bundle.type}".`, + codeFrames: [ + { + filePath: loc.filePath, + codeHighlights: [ + convertSourceLocationToHighlight( + loc, + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + md`Did you mean "${ + fullName.slice(0, -path.extname(fullName).length) + + '.' + + bundle.type + }"?`, + ), + ], + }, + ], + hints: [ + `Try changing the file extension of "${ + bundle.target.name + }" in ${path.relative(process.cwd(), loc.filePath)}.`, + ], + }, + }); + throw err; + } + + return bundle.target.distEntry; + } + + // Base split bundle names on the first bundle in their group. + // e.g. if `index.js` imports `foo.css`, the css bundle should be called + // `index.css`. + let name = nameFromContent( + mainBundle, + isEntry, + bundleGroup.entryAssetId, + bundleGraph.getEntryRoot(bundle.target), + ); + if (!bundle.needsStableName) { + name += '.' + bundle.hashReference; + } + + return name + '.' + bundle.type; + }, +}) as Namer; + +function nameFromContent( + bundle: Bundle, + isEntry: boolean, + entryAssetId: string, + entryRoot: FilePath, +): string { + let entryFilePath = nullthrows( + bundle.getEntryAssets().find((a) => a.id === entryAssetId), + ).filePath; + let name = basenameWithoutExtension(entryFilePath); + + // If this is an entry bundle, use the original relative path. + if (bundle.needsStableName) { + // Match name of target entry if possible, but with a different extension. + if (isEntry && bundle.target.distEntry != null) { + return basenameWithoutExtension(bundle.target.distEntry); + } + + return path + .join(path.relative(entryRoot, path.dirname(entryFilePath)), name) + .replace(/\.\.(\/|\\)/g, 'up_$1'); + } else { + // If this is an index file or common directory name, use the parent + // directory name instead, which is probably more descriptive. + while (COMMON_NAMES.has(name)) { + entryFilePath = path.dirname(entryFilePath); + name = path.basename(entryFilePath); + if (name.startsWith('.')) { + name = name.replace('.', ''); + } + } + + return name || 'bundle'; + } +} + +function basenameWithoutExtension(file: FilePath) { + return path.basename(file, path.extname(file)); +} diff --git a/packages/optimizers/blob-url/package.json b/packages/optimizers/blob-url/package.json index 6fd71ce28..5be4e5c3e 100644 --- a/packages/optimizers/blob-url/package.json +++ b/packages/optimizers/blob-url/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/BlobURLOptimizer.js", - "source": "src/BlobURLOptimizer.js", + "types": "src/BlobURLOptimizer.ts", + "source": "src/BlobURLOptimizer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/optimizers/blob-url/src/BlobURLOptimizer.js b/packages/optimizers/blob-url/src/BlobURLOptimizer.js deleted file mode 100644 index ea57f2e28..000000000 --- a/packages/optimizers/blob-url/src/BlobURLOptimizer.js +++ /dev/null @@ -1,21 +0,0 @@ -// @flow strict-local - -import {Optimizer} from '@atlaspack/plugin'; -import {blobToString} from '@atlaspack/utils'; - -export default (new Optimizer({ - async optimize({contents}) { - // Inspired by webpack's worker plugin: - // https://github.com/webpack-contrib/worker-loader/blob/b82585a1ddb8ae295fd4b1c302bca6b162665de2/src/workers/InlineWorker.js - // which itself draws from: - // http://stackoverflow.com/questions/10343913/how-to-create-a-web-worker-from-a-string - // - // This version only uses the Blob constructor, which is available in IE 10+: - // https://developer.mozilla.org/en-US/docs/Web/API/Blob - return { - contents: `URL.createObjectURL(new Blob([${JSON.stringify( - await blobToString(contents), - )}]))`, - }; - }, -}): Optimizer); diff --git a/packages/optimizers/blob-url/src/BlobURLOptimizer.ts b/packages/optimizers/blob-url/src/BlobURLOptimizer.ts new file mode 100644 index 000000000..a0d8e5228 --- /dev/null +++ b/packages/optimizers/blob-url/src/BlobURLOptimizer.ts @@ -0,0 +1,19 @@ +import {Optimizer} from '@atlaspack/plugin'; +import {blobToString} from '@atlaspack/utils'; + +export default new Optimizer({ + async optimize({contents}) { + // Inspired by webpack's worker plugin: + // https://github.com/webpack-contrib/worker-loader/blob/b82585a1ddb8ae295fd4b1c302bca6b162665de2/src/workers/InlineWorker.js + // which itself draws from: + // http://stackoverflow.com/questions/10343913/how-to-create-a-web-worker-from-a-string + // + // This version only uses the Blob constructor, which is available in IE 10+: + // https://developer.mozilla.org/en-US/docs/Web/API/Blob + return { + contents: `URL.createObjectURL(new Blob([${JSON.stringify( + await blobToString(contents), + )}]))`, + }; + }, +}) as Optimizer; diff --git a/packages/optimizers/css/package.json b/packages/optimizers/css/package.json index ff663d146..fab316787 100644 --- a/packages/optimizers/css/package.json +++ b/packages/optimizers/css/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/CSSOptimizer.js", - "source": "src/CSSOptimizer.js", + "types": "src/CSSOptimizer.ts", + "source": "src/CSSOptimizer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/optimizers/css/src/CSSOptimizer.js b/packages/optimizers/css/src/CSSOptimizer.js deleted file mode 100644 index 8312cc5db..000000000 --- a/packages/optimizers/css/src/CSSOptimizer.js +++ /dev/null @@ -1,219 +0,0 @@ -// @flow strict-local - -import SourceMap from '@parcel/source-map'; -import {Optimizer} from '@atlaspack/plugin'; -// $FlowFixMe - init for browser build. -import init, { - transform, - transformStyleAttribute, - browserslistToTargets, -} from 'lightningcss'; -import {blobToBuffer} from '@atlaspack/utils'; -import browserslist from 'browserslist'; -import nullthrows from 'nullthrows'; -import path from 'path'; -import { - convertSourceLocationToHighlight, - md, - generateJSONCodeHighlights, -} from '@atlaspack/diagnostic'; - -export default (new Optimizer({ - async loadConfig({config, logger, options}) { - const configFile = await config.getConfig( - [ - '.cssnanorc', - 'cssnano.config.json', - 'cssnano.config.js', - 'cssnano.config.cjs', - ], - { - packageKey: 'cssnano', - }, - ); - if (configFile) { - let filename = path.basename(configFile.filePath); - let codeHighlights; - let message; - if (filename === 'package.json') { - message = md` -Atlaspack\'s default CSS minifer changed from cssnano to lightningcss, but a "cssnano" key was found in **package.json**. Either remove this configuration, or configure Parcel to use @atlaspack/optimizer-cssnano instead. - `; - let contents = await options.inputFS.readFile( - configFile.filePath, - 'utf8', - ); - codeHighlights = generateJSONCodeHighlights(contents, [ - {key: '/cssnano', type: 'key'}, - ]); - } else { - message = md`Parcel\'s default CSS minifer changed from cssnano to lightningcss, but a __${filename}__ config file was found. Either remove this config file, or configure Parcel to use @atlaspack/optimizer-cssnano instead.`; - codeHighlights = [ - { - start: {line: 1, column: 1}, - end: {line: 1, column: 1}, - }, - ]; - } - - logger.warn({ - message, - documentationURL: 'https://parceljs.org/languages/css/#minification', - codeFrames: [ - { - filePath: configFile.filePath, - codeHighlights, - }, - ], - }); - } - }, - async optimize({ - bundle, - bundleGraph, - logger, - contents: prevContents, - getSourceMapReference, - map: prevMap, - options, - }) { - if (!bundle.env.shouldOptimize) { - return {contents: prevContents, map: prevMap}; - } - - let targets = getTargets(bundle.env.engines.browsers); - let code = await blobToBuffer(prevContents); - - let unusedSymbols; - if (bundle.env.shouldScopeHoist) { - unusedSymbols = []; - bundle.traverseAssets(asset => { - if ( - asset.symbols.isCleared || - asset.meta.cssModulesCompiled === 'postcss' - ) { - return; - } - - let usedSymbols = bundleGraph.getUsedSymbols(asset); - if (usedSymbols == null) { - return; - } - - let defaultImport = null; - if (usedSymbols.has('default')) { - let incoming = bundleGraph.getIncomingDependencies(asset); - defaultImport = incoming.find(d => - d.symbols.hasExportSymbol('default'), - ); - if (defaultImport) { - let loc = defaultImport.symbols.get('default')?.loc; - logger.warn({ - message: - 'CSS modules cannot be tree shaken when imported with a default specifier', - ...(loc && { - codeFrames: [ - { - filePath: nullthrows( - loc?.filePath ?? defaultImport.sourcePath, - ), - codeHighlights: [convertSourceLocationToHighlight(loc)], - }, - ], - }), - hints: [ - `Instead do: import * as style from "${defaultImport.specifier}";`, - ], - documentationURL: - 'https://parceljs.org/languages/css/#tree-shaking', - }); - } - } - - if (!defaultImport && !usedSymbols.has('*')) { - for (let [symbol, {local}] of asset.symbols) { - if (local !== 'default' && !usedSymbols.has(symbol)) { - unusedSymbols.push(local); - } - } - } - }); - } - - // Inline style attributes in HTML need to be parsed differently from full CSS files. - if (bundle.bundleBehavior === 'inline') { - let entry = bundle.getMainEntry(); - if (entry?.meta.type === 'attr') { - let result = transformStyleAttribute({ - code, - minify: true, - targets, - }); - - return { - contents: Buffer.from(result.code), - }; - } - } - - // $FlowFixMe - if (process.browser) { - await init(); - } - - let result = transform({ - filename: bundle.name, - code, - minify: true, - sourceMap: !!bundle.env.sourceMap, - targets, - unusedSymbols, - }); - - let map; - if (result.map != null) { - let vlqMap = JSON.parse(Buffer.from(result.map).toString()); - map = new SourceMap(options.projectRoot); - map.addVLQMap(vlqMap); - if (prevMap) { - map.extends(prevMap); - } - } - - let contents = Buffer.from(result.code); - if (bundle.env.sourceMap) { - let reference = await getSourceMapReference(map); - if (reference != null) { - contents = - contents.toString() + - '\n' + - '/*# sourceMappingURL=' + - reference + - ' */\n'; - } - } - - return { - contents: Buffer.from(contents), - map, - }; - }, -}): Optimizer); - -let cache = new Map(); - -function getTargets(browsers) { - if (browsers == null) { - return undefined; - } - - let cached = cache.get(browsers); - if (cached != null) { - return cached; - } - - let targets = browserslistToTargets(browserslist(browsers)); - - cache.set(browsers, targets); - return targets; -} diff --git a/packages/optimizers/css/src/CSSOptimizer.ts b/packages/optimizers/css/src/CSSOptimizer.ts new file mode 100644 index 000000000..edb428914 --- /dev/null +++ b/packages/optimizers/css/src/CSSOptimizer.ts @@ -0,0 +1,224 @@ +import SourceMap from '@parcel/source-map'; +import {Optimizer} from '@atlaspack/plugin'; +import init, { + transform, + transformStyleAttribute, + browserslistToTargets, +} from 'lightningcss'; +import {blobToBuffer} from '@atlaspack/utils'; +import browserslist from 'browserslist'; +import nullthrows from 'nullthrows'; +import path from 'path'; +import { + convertSourceLocationToHighlight, + md, + generateJSONCodeHighlights, +} from '@atlaspack/diagnostic'; + +export default new Optimizer({ + async loadConfig({config, logger, options}) { + const configFile = await config.getConfig( + [ + '.cssnanorc', + 'cssnano.config.json', + 'cssnano.config.js', + 'cssnano.config.cjs', + ], + { + packageKey: 'cssnano', + }, + ); + if (configFile) { + let filename = path.basename(configFile.filePath); + let codeHighlights; + let message; + if (filename === 'package.json') { + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message = md` +Atlaspack\'s default CSS minifer changed from cssnano to lightningcss, but a "cssnano" key was found in **package.json**. Either remove this configuration, or configure Parcel to use @atlaspack/optimizer-cssnano instead. + `; + let contents = await options.inputFS.readFile( + configFile.filePath, + 'utf8', + ); + codeHighlights = generateJSONCodeHighlights(contents, [ + {key: '/cssnano', type: 'key'}, + ]); + } else { + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message = md`Parcel\'s default CSS minifer changed from cssnano to lightningcss, but a __${filename}__ config file was found. Either remove this config file, or configure Parcel to use @atlaspack/optimizer-cssnano instead.`; + codeHighlights = [ + { + start: {line: 1, column: 1}, + end: {line: 1, column: 1}, + }, + ]; + } + + logger.warn({ + message, + documentationURL: 'https://parceljs.org/languages/css/#minification', + codeFrames: [ + { + filePath: configFile.filePath, + codeHighlights, + }, + ], + }); + } + }, + async optimize({ + bundle, + bundleGraph, + logger, + contents: prevContents, + getSourceMapReference, + map: prevMap, + options, + }) { + if (!bundle.env.shouldOptimize) { + return {contents: prevContents, map: prevMap}; + } + + let targets = getTargets(bundle.env.engines.browsers); + let code = await blobToBuffer(prevContents); + + let unusedSymbols; + if (bundle.env.shouldScopeHoist) { + unusedSymbols = []; + bundle.traverseAssets((asset) => { + if ( + asset.symbols.isCleared || + asset.meta.cssModulesCompiled === 'postcss' + ) { + return; + } + + let usedSymbols = bundleGraph.getUsedSymbols(asset); + if (usedSymbols == null) { + return; + } + + let defaultImport = null; + if (usedSymbols.has('default')) { + let incoming = bundleGraph.getIncomingDependencies(asset); + defaultImport = incoming.find((d) => + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + d.symbols.hasExportSymbol('default'), + ); + if (defaultImport) { + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + let loc = defaultImport.symbols.get('default')?.loc; + logger.warn({ + message: + 'CSS modules cannot be tree shaken when imported with a default specifier', + ...(loc && { + codeFrames: [ + { + filePath: nullthrows( + loc?.filePath ?? defaultImport.sourcePath, + ), + codeHighlights: [convertSourceLocationToHighlight(loc)], + }, + ], + }), + hints: [ + `Instead do: import * as style from "${defaultImport.specifier}";`, + ], + documentationURL: + 'https://parceljs.org/languages/css/#tree-shaking', + }); + } + } + + if (!defaultImport && !usedSymbols.has('*')) { + for (let [symbol, {local}] of asset.symbols) { + // @ts-expect-error - TS2367 - This condition will always return 'true' since the types 'symbol' and 'string' have no overlap. + if (local !== 'default' && !usedSymbols.has(symbol)) { + unusedSymbols.push(local); + } + } + } + }); + } + + // Inline style attributes in HTML need to be parsed differently from full CSS files. + if (bundle.bundleBehavior === 'inline') { + let entry = bundle.getMainEntry(); + if (entry?.meta.type === 'attr') { + let result = transformStyleAttribute({ + code, + minify: true, + targets, + }); + + return { + contents: Buffer.from(result.code), + }; + } + } + + // @ts-expect-error - TS2339 - Property 'browser' does not exist on type 'Process'. + if (process.browser) { + // @ts-expect-error - TS2349 - This expression is not callable. + await init(); + } + + let result = transform({ + filename: bundle.name, + code, + minify: true, + sourceMap: !!bundle.env.sourceMap, + targets, + unusedSymbols, + }); + + let map; + if (result.map != null) { + let vlqMap = JSON.parse(Buffer.from(result.map).toString()); + map = new SourceMap(options.projectRoot); + map.addVLQMap(vlqMap); + if (prevMap) { + // @ts-expect-error - TS2345 - Argument of type 'SourceMap' is not assignable to parameter of type 'Buffer'. + map.extends(prevMap); + } + } + + let contents = Buffer.from(result.code); + if (bundle.env.sourceMap) { + let reference = await getSourceMapReference(map); + if (reference != null) { + // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'Buffer'. + contents = + contents.toString() + + '\n' + + '/*# sourceMappingURL=' + + reference + + ' */\n'; + } + } + + return { + contents: Buffer.from(contents), + map, + }; + }, +}) as Optimizer; + +let cache = new Map(); + +function getTargets(browsers: undefined | string | Array) { + if (browsers == null) { + return undefined; + } + + let cached = cache.get(browsers); + if (cached != null) { + return cached; + } + + let targets = browserslistToTargets(browserslist(browsers)); + + cache.set(browsers, targets); + return targets; +} diff --git a/packages/optimizers/cssnano/package.json b/packages/optimizers/cssnano/package.json index b7026b9cb..4f6b26311 100644 --- a/packages/optimizers/cssnano/package.json +++ b/packages/optimizers/cssnano/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/CSSNanoOptimizer.js", - "source": "src/CSSNanoOptimizer.js", + "types": "src/CSSNanoOptimizer.ts", + "source": "src/CSSNanoOptimizer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/optimizers/cssnano/src/CSSNanoOptimizer.js b/packages/optimizers/cssnano/src/CSSNanoOptimizer.js deleted file mode 100644 index 9abe2fb25..000000000 --- a/packages/optimizers/cssnano/src/CSSNanoOptimizer.js +++ /dev/null @@ -1,78 +0,0 @@ -// @flow strict-local - -import SourceMap from '@parcel/source-map'; -import {Optimizer} from '@atlaspack/plugin'; -import postcss from 'postcss'; -import cssnano from 'cssnano'; -import type {CSSNanoOptions} from 'cssnano'; // TODO the type is based on cssnano 4 - -export default (new Optimizer({ - async loadConfig({config}) { - const configFile = await config.getConfig( - [ - '.cssnanorc', - 'cssnano.config.json', - 'cssnano.config.js', - 'cssnano.config.cjs', - 'cssnano.config.mjs', - ], - { - packageKey: 'cssnano', - }, - ); - if (configFile) { - return configFile.contents; - } - }, - - async optimize({ - bundle, - contents: prevContents, - getSourceMapReference, - map: prevMap, - config, - options, - }) { - if (!bundle.env.shouldOptimize) { - return {contents: prevContents, map: prevMap}; - } - - if (typeof prevContents !== 'string') { - throw new Error( - 'CSSNanoOptimizer: Only string contents are currently supported', - ); - } - - const result = await postcss([ - cssnano((config ?? {}: CSSNanoOptions)), - ]).process(prevContents, { - // Suppress postcss's warning about a missing `from` property. In this - // case, the input map contains all of the sources. - from: undefined, - map: { - annotation: false, - inline: false, - prev: prevMap ? await prevMap.stringify({}) : null, - }, - }); - - let map; - if (result.map != null) { - map = new SourceMap(options.projectRoot); - map.addVLQMap(result.map.toJSON()); - } - - let contents = result.css; - if (bundle.env.sourceMap) { - let reference = await getSourceMapReference(map); - if (reference != null) { - contents += '\n' + '/*# sourceMappingURL=' + reference + ' */\n'; - } - } - - return { - contents, - map, - }; - }, -}): Optimizer); diff --git a/packages/optimizers/cssnano/src/CSSNanoOptimizer.ts b/packages/optimizers/cssnano/src/CSSNanoOptimizer.ts new file mode 100644 index 000000000..4bf389413 --- /dev/null +++ b/packages/optimizers/cssnano/src/CSSNanoOptimizer.ts @@ -0,0 +1,80 @@ +import SourceMap from '@parcel/source-map'; +import {Optimizer} from '@atlaspack/plugin'; +import postcss from 'postcss'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'cssnano'. '/home/ubuntu/parcel/node_modules/cssnano/dist/index.js' implicitly has an 'any' type. +import cssnano from 'cssnano'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'cssnano'. '/home/ubuntu/parcel/node_modules/cssnano/dist/index.js' implicitly has an 'any' type. +import type {CSSNanoOptions} from 'cssnano'; // TODO the type is based on cssnano 4 + +export default new Optimizer({ + async loadConfig({config}) { + const configFile = await config.getConfig( + [ + '.cssnanorc', + 'cssnano.config.json', + 'cssnano.config.js', + 'cssnano.config.cjs', + 'cssnano.config.mjs', + ], + { + packageKey: 'cssnano', + }, + ); + if (configFile) { + return configFile.contents; + } + }, + + async optimize({ + bundle, + contents: prevContents, + getSourceMapReference, + map: prevMap, + config, + options, + }) { + if (!bundle.env.shouldOptimize) { + return {contents: prevContents, map: prevMap}; + } + + if (typeof prevContents !== 'string') { + throw new Error( + 'CSSNanoOptimizer: Only string contents are currently supported', + ); + } + + const result = await postcss([ + cssnano(config ?? ({} as CSSNanoOptions)), + ]).process(prevContents, { + // Suppress postcss's warning about a missing `from` property. In this + // case, the input map contains all of the sources. + from: undefined, + map: { + annotation: false, + inline: false, + // @ts-expect-error - TS2322 - Type 'string | Readonly<{ sources: readonly string[]; sourcesContent?: readonly (string | null)[] | undefined; names: readonly string[]; mappings: string; version?: number | undefined; file?: string | undefined; sourceRoot?: string | undefined; }> | null' is not assignable to type 'string | boolean | object | ((file: string) => string) | undefined'. + prev: prevMap ? await prevMap.stringify({}) : null, + }, + }); + + let map; + if (result.map != null) { + map = new SourceMap(options.projectRoot); + // @ts-expect-error - TS2345 - Argument of type 'RawSourceMap' is not assignable to parameter of type 'Readonly<{ sources: readonly string[]; sourcesContent?: readonly (string | null)[] | undefined; names: readonly string[]; mappings: string; version?: number | undefined; file?: string | undefined; sourceRoot?: string | undefined; }>'. + map.addVLQMap(result.map.toJSON()); + } + + let contents = result.css; + if (bundle.env.sourceMap) { + let reference = await getSourceMapReference(map); + if (reference != null) { + contents += '\n' + '/*# sourceMappingURL=' + reference + ' */\n'; + } + } + + return { + contents, + map, + }; + }, +}) as Optimizer; diff --git a/packages/optimizers/data-url/package.json b/packages/optimizers/data-url/package.json index 5e1a87486..0ab2eff0a 100644 --- a/packages/optimizers/data-url/package.json +++ b/packages/optimizers/data-url/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/DataURLOptimizer.js", - "source": "src/DataURLOptimizer.js", + "types": "src/DataURLOptimizer.ts", + "source": "src/DataURLOptimizer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/optimizers/data-url/src/DataURLOptimizer.js b/packages/optimizers/data-url/src/DataURLOptimizer.js deleted file mode 100644 index 164ee859d..000000000 --- a/packages/optimizers/data-url/src/DataURLOptimizer.js +++ /dev/null @@ -1,32 +0,0 @@ -// @flow strict-local - -import {Optimizer} from '@atlaspack/plugin'; -import {blobToBuffer} from '@atlaspack/utils'; -import mime from 'mime'; -import {isBinaryFile} from 'isbinaryfile'; - -const fixedEncodeURIComponent = (str: string): string => { - return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { - return '%' + c.charCodeAt(0).toString(16); - }); -}; - -export default (new Optimizer({ - async optimize({bundle, contents}) { - let bufferContents = await blobToBuffer(contents); - let hasBinaryContent = await isBinaryFile(bufferContents); - - // Follows the data url format referenced here: - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs - let mimeType = mime.getType(bundle.name) ?? ''; - let encoding = hasBinaryContent ? ';base64' : ''; - let content = fixedEncodeURIComponent( - hasBinaryContent - ? bufferContents.toString('base64') - : bufferContents.toString(), - ); - return { - contents: `data:${mimeType}${encoding},${content}`, - }; - }, -}): Optimizer); diff --git a/packages/optimizers/data-url/src/DataURLOptimizer.ts b/packages/optimizers/data-url/src/DataURLOptimizer.ts new file mode 100644 index 000000000..9036d222a --- /dev/null +++ b/packages/optimizers/data-url/src/DataURLOptimizer.ts @@ -0,0 +1,31 @@ +import {Optimizer} from '@atlaspack/plugin'; +import {blobToBuffer} from '@atlaspack/utils'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'mime'. '/home/ubuntu/parcel/packages/optimizers/data-url/node_modules/mime/index.js' implicitly has an 'any' type. +import mime from 'mime'; +import {isBinaryFile} from 'isbinaryfile'; + +const fixedEncodeURIComponent = (str: string): string => { + return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { + return '%' + c.charCodeAt(0).toString(16); + }); +}; + +export default new Optimizer({ + async optimize({bundle, contents}) { + let bufferContents = await blobToBuffer(contents); + let hasBinaryContent = await isBinaryFile(bufferContents); + + // Follows the data url format referenced here: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + let mimeType = mime.getType(bundle.name) ?? ''; + let encoding = hasBinaryContent ? ';base64' : ''; + let content = fixedEncodeURIComponent( + hasBinaryContent + ? bufferContents.toString('base64') + : bufferContents.toString(), + ); + return { + contents: `data:${mimeType}${encoding},${content}`, + }; + }, +}) as Optimizer; diff --git a/packages/optimizers/htmlnano/package.json b/packages/optimizers/htmlnano/package.json index faaebf1d8..052ca0d4b 100644 --- a/packages/optimizers/htmlnano/package.json +++ b/packages/optimizers/htmlnano/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/HTMLNanoOptimizer.js", - "source": "src/HTMLNanoOptimizer.js", + "types": "src/HTMLNanoOptimizer.ts", + "source": "src/HTMLNanoOptimizer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/optimizers/htmlnano/src/HTMLNanoOptimizer.js b/packages/optimizers/htmlnano/src/HTMLNanoOptimizer.js deleted file mode 100644 index 0a7c236ff..000000000 --- a/packages/optimizers/htmlnano/src/HTMLNanoOptimizer.js +++ /dev/null @@ -1,160 +0,0 @@ -// @flow strict-local -import type {PostHTMLNode} from 'posthtml'; - -import htmlnano from 'htmlnano'; -import {Optimizer} from '@atlaspack/plugin'; -import posthtml from 'posthtml'; -import path from 'path'; -import {SVG_ATTRS, SVG_TAG_NAMES} from './svgMappings'; - -export default (new Optimizer({ - async loadConfig({config, options}) { - let userConfig = await config.getConfigFrom( - path.join(options.projectRoot, 'index.html'), - [ - '.htmlnanorc', - '.htmlnanorc.json', - '.htmlnanorc.js', - '.htmlnanorc.cjs', - '.htmlnanorc.mjs', - 'htmlnano.config.js', - 'htmlnano.config.cjs', - 'htmlnano.config.mjs', - ], - { - packageKey: 'htmlnano', - }, - ); - - return userConfig?.contents; - }, - async optimize({bundle, contents, map, config}) { - if (!bundle.env.shouldOptimize) { - return {contents, map}; - } - - if (typeof contents !== 'string') { - throw new Error( - 'HTMLNanoOptimizer: Only string contents are currently supported', - ); - } - - const clonedConfig = config || {}; - - // $FlowFixMe - const presets = htmlnano.presets; - const preset = - typeof clonedConfig.preset === 'string' - ? presets[clonedConfig.preset] - : {}; - delete clonedConfig.preset; - - const htmlNanoConfig = { - // Inline { - res = node; - return node; - }); - - return res; -} - -function findBundleInsertIndex(content) { - // HTML document order (https://html.spec.whatwg.org/multipage/syntax.html#writing) - // - Any number of comments and ASCII whitespace. - // - A DOCTYPE. - // - Any number of comments and ASCII whitespace. - // - The document element, in the form of an html element. - // - Any number of comments and ASCII whitespace. - // - // -> Insert before first non-metadata (or script) element; if none was found, after the doctype - - let doctypeIndex; - for (let index = 0; index < content.length; index++) { - const node = content[index]; - if (node && node.tag && !metadataContent.has(node.tag)) { - return index; - } - if ( - typeof node === 'string' && - node.toLowerCase().startsWith(' = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.equal(assets.length, 1, 'HTML bundles must only contain one asset'); + + let asset = assets[0]; + let code = await asset.getCode(); + + // Add bundles in the same bundle group that are not inline. For example, if two inline + // bundles refer to the same library that is extracted into a shared bundle. + let referencedBundles = [ + ...setDifference( + new Set(bundleGraph.getReferencedBundles(bundle)), + new Set(bundleGraph.getReferencedBundles(bundle, {recursive: false})), + ), + ]; + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + let renderConfig = config?.render; + + let {html} = await posthtml([ + // @ts-expect-error - TS2345 - Argument of type 'unknown[]' is not assignable to parameter of type 'NamedBundle[]'. + (tree: any) => insertBundleReferences(referencedBundles, tree), + (tree: any) => + replaceInlineAssetContent(bundleGraph, getInlineBundleContents, tree), + ]).process(code, { + ...renderConfig, + xmlMode: bundle.type === 'xhtml', + closingSingleTag: bundle.type === 'xhtml' ? 'slash' : undefined, + }); + + let {contents, map} = replaceURLReferences({ + bundle, + bundleGraph, + contents: html, + relative: false, + getReplacement: (contents) => contents.replace(/"/g, '"'), + }); + + return replaceInlineReferences({ + bundle, + bundleGraph, + contents, + getInlineBundleContents, + getInlineReplacement: (dep, inlineType, contents) => ({ + from: dep.id, + to: contents.replace(/"/g, '"').trim(), + }), + map, + }); + }, +}) as Packager; + +async function getAssetContent( + bundleGraph: BundleGraph, + getInlineBundleContents: ( + arg1: Bundle, + arg2: BundleGraph, + ) => Async<{ + contents: Blob; + }>, + assetId: any, +) { + let inlineBundle: Bundle | null | undefined; + bundleGraph.traverseBundles((bundle, context, {stop}) => { + let entryAssets = bundle.getEntryAssets(); + if (entryAssets.some((a) => a.uniqueKey === assetId)) { + inlineBundle = bundle; + stop(); + } + }); + + if (inlineBundle) { + const bundleResult = await getInlineBundleContents( + inlineBundle, + bundleGraph, + ); + + return {bundle: inlineBundle, contents: bundleResult.contents}; + } + + return null; +} + +async function replaceInlineAssetContent( + bundleGraph: BundleGraph, + getInlineBundleContents: ( + arg1: Bundle, + arg2: BundleGraph, + ) => Async<{ + contents: Blob; + }>, + tree: any, +) { + const inlineNodes: Array = []; + // @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type. + tree.walk((node) => { + if (node.attrs && node.attrs['data-parcel-key']) { + inlineNodes.push(node); + } + return node; + }); + + for (let node of inlineNodes) { + let newContent = await getAssetContent( + bundleGraph, + getInlineBundleContents, + node.attrs['data-parcel-key'], + ); + + if (newContent != null) { + let {contents, bundle} = newContent; + node.content = ( + contents instanceof Readable ? await bufferStream(contents) : contents + ).toString(); + + if ( + node.tag === 'script' && + nullthrows(bundle).env.outputFormat === 'esmodule' + ) { + node.attrs.type = 'module'; + } + + // Escape closing script tags and HTML comments in JS content. + // https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements + // Avoid replacing , tree: any) { + const bundles = []; + + for (let bundle of siblingBundles) { + if (bundle.type === 'css') { + bundles.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: urlJoin(bundle.target.publicUrl, bundle.name), + }, + }); + } else if (bundle.type === 'js') { + let nomodule = + bundle.env.outputFormat !== 'esmodule' && + bundle.env.sourceType === 'module' && + bundle.env.shouldScopeHoist; + bundles.push({ + tag: 'script', + attrs: { + type: bundle.env.outputFormat === 'esmodule' ? 'module' : undefined, + nomodule: nomodule ? '' : undefined, + defer: nomodule ? '' : undefined, + src: urlJoin(bundle.target.publicUrl, bundle.name), + }, + }); + } + } + + addBundlesToTree(bundles, tree); +} + +// @ts-expect-error - TS7006 - Parameter 'bundles' implicitly has an 'any' type. +function addBundlesToTree(bundles, tree: any) { + const main = find(tree, 'head') || find(tree, 'html'); + // @ts-expect-error - TS2339 - Property 'content' does not exist on type 'never'. | TS2339 - Property 'content' does not exist on type 'never'. + const content = main ? main.content || (main.content = []) : tree; + const index = findBundleInsertIndex(content); + + content.splice(index, 0, ...bundles); +} + +function find(tree: any, tag: string) { + let res; + // @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type. + tree.match({tag}, (node) => { + res = node; + return node; + }); + + return res; +} + +function findBundleInsertIndex(content: any) { + // HTML document order (https://html.spec.whatwg.org/multipage/syntax.html#writing) + // - Any number of comments and ASCII whitespace. + // - A DOCTYPE. + // - Any number of comments and ASCII whitespace. + // - The document element, in the form of an html element. + // - Any number of comments and ASCII whitespace. + // + // -> Insert before first non-metadata (or script) element; if none was found, after the doctype + + let doctypeIndex; + for (let index = 0; index < content.length; index++) { + const node = content[index]; + if (node && node.tag && !metadataContent.has(node.tag)) { + return index; + } + if ( + typeof node === 'string' && + node.toLowerCase().startsWith('= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/js/src/CJSOutputFormat.js b/packages/packagers/js/src/CJSOutputFormat.js deleted file mode 100644 index 899341f43..000000000 --- a/packages/packagers/js/src/CJSOutputFormat.js +++ /dev/null @@ -1,42 +0,0 @@ -// @flow -import type { - ScopeHoistingPackager, - OutputFormat, -} from './ScopeHoistingPackager'; - -export class CJSOutputFormat implements OutputFormat { - packager: ScopeHoistingPackager; - - constructor(packager: ScopeHoistingPackager) { - this.packager = packager; - } - - buildBundlePrelude(): [string, number] { - let res = ''; - let lines = 0; - - for (let [source, specifiers] of this.packager.externals) { - // CJS only supports the namespace symbol. This ensures that all accesses - // are live and the `this` binding is correct. - let namespace = specifiers.get('*'); - if (namespace) { - res += `var ${namespace} = require(${JSON.stringify(source)});\n`; - lines++; - } else { - res += `require(${JSON.stringify(source)});\n`; - lines++; - } - } - - if (res.length > 0) { - res += '\n'; - lines++; - } - - return [res, lines]; - } - - buildBundlePostlude(): [string, number] { - return ['', 0]; - } -} diff --git a/packages/packagers/js/src/CJSOutputFormat.ts b/packages/packagers/js/src/CJSOutputFormat.ts new file mode 100644 index 000000000..ec2236d64 --- /dev/null +++ b/packages/packagers/js/src/CJSOutputFormat.ts @@ -0,0 +1,41 @@ +import type { + ScopeHoistingPackager, + OutputFormat, +} from './ScopeHoistingPackager'; + +export class CJSOutputFormat implements OutputFormat { + packager: ScopeHoistingPackager; + + constructor(packager: ScopeHoistingPackager) { + this.packager = packager; + } + + buildBundlePrelude(): [string, number] { + let res = ''; + let lines = 0; + + for (let [source, specifiers] of this.packager.externals) { + // CJS only supports the namespace symbol. This ensures that all accesses + // are live and the `this` binding is correct. + let namespace = specifiers.get('*'); + if (namespace) { + res += `var ${namespace} = require(${JSON.stringify(source)});\n`; + lines++; + } else { + res += `require(${JSON.stringify(source)});\n`; + lines++; + } + } + + if (res.length > 0) { + res += '\n'; + lines++; + } + + return [res, lines]; + } + + buildBundlePostlude(): [string, number] { + return ['', 0]; + } +} diff --git a/packages/packagers/js/src/DevPackager.js b/packages/packagers/js/src/DevPackager.js deleted file mode 100644 index ea0ee0cbd..000000000 --- a/packages/packagers/js/src/DevPackager.js +++ /dev/null @@ -1,252 +0,0 @@ -// @flow strict-local -import type {BundleGraph, PluginOptions, NamedBundle} from '@atlaspack/types'; - -import { - PromiseQueue, - relativeBundlePath, - countLines, - normalizeSeparators, -} from '@atlaspack/utils'; -import SourceMap from '@parcel/source-map'; -import invariant from 'assert'; -import path from 'path'; -import fs from 'fs'; -import {replaceScriptDependencies, getSpecifier} from './utils'; - -const PRELUDE = fs - .readFileSync(path.join(__dirname, 'dev-prelude.js'), 'utf8') - .trim() - .replace(/;$/, ''); - -export class DevPackager { - options: PluginOptions; - bundleGraph: BundleGraph; - bundle: NamedBundle; - parcelRequireName: string; - - constructor( - options: PluginOptions, - bundleGraph: BundleGraph, - bundle: NamedBundle, - parcelRequireName: string, - ) { - this.options = options; - this.bundleGraph = bundleGraph; - this.bundle = bundle; - this.parcelRequireName = parcelRequireName; - } - - async package(): Promise<{|contents: string, map: ?SourceMap|}> { - // Load assets - let queue = new PromiseQueue({maxConcurrent: 32}); - this.bundle.traverseAssets(asset => { - queue.add(async () => { - let [code, mapBuffer] = await Promise.all([ - asset.getCode(), - this.bundle.env.sourceMap && asset.getMapBuffer(), - ]); - return {code, mapBuffer}; - }); - }); - - let results = await queue.run(); - - let assets = ''; - let i = 0; - let first = true; - let map = new SourceMap(this.options.projectRoot); - - let prefix = this.getPrefix(); - let lineOffset = countLines(prefix); - let script: ?{|code: string, mapBuffer: ?Buffer|} = null; - - this.bundle.traverse(node => { - let wrapped = first ? '' : ','; - - if (node.type === 'dependency') { - let resolved = this.bundleGraph.getResolvedAsset( - node.value, - this.bundle, - ); - if (resolved && resolved.type !== 'js') { - // if this is a reference to another javascript asset, we should not include - // its output, as its contents should already be loaded. - invariant(!this.bundle.hasAsset(resolved)); - wrapped += - JSON.stringify(this.bundleGraph.getAssetPublicId(resolved)) + - ':[function() {},{}]'; - } else { - return; - } - } - - if (node.type === 'asset') { - let asset = node.value; - invariant( - asset.type === 'js', - 'all assets in a js bundle must be js assets', - ); - - // If this is the main entry of a script rather than a module, we need to hoist it - // outside the bundle wrapper function so that its variables are exposed as globals. - if ( - this.bundle.env.sourceType === 'script' && - asset === this.bundle.getMainEntry() - ) { - script = results[i++]; - return; - } - - let deps = {}; - let dependencies = this.bundleGraph.getDependencies(asset); - for (let dep of dependencies) { - let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); - let specifier = getSpecifier(dep); - if (this.bundleGraph.isDependencySkipped(dep)) { - deps[specifier] = false; - } else if (resolved) { - deps[specifier] = this.bundleGraph.getAssetPublicId(resolved); - } else { - // An external module - map placeholder to original specifier. - deps[specifier] = dep.specifier; - } - } - - let {code, mapBuffer} = results[i]; - let output = code || ''; - wrapped += - JSON.stringify(this.bundleGraph.getAssetPublicId(asset)) + - ':[function(require,module,exports,__globalThis) {\n' + - output + - '\n},'; - wrapped += JSON.stringify(deps); - wrapped += ']'; - - if ( - this.bundle.env.isNode() && - asset.meta.has_node_replacements === true - ) { - const relPath = normalizeSeparators( - path.relative( - this.bundle.target.distDir, - path.dirname(asset.filePath), - ), - ); - wrapped = wrapped.replace('$parcel$dirnameReplace', relPath); - wrapped = wrapped.replace('$parcel$filenameReplace', relPath); - } - - if (this.bundle.env.sourceMap) { - if (mapBuffer) { - map.addBuffer(mapBuffer, lineOffset); - } else { - map.addEmptyMap( - path - .relative(this.options.projectRoot, asset.filePath) - .replace(/\\+/g, '/'), - output, - lineOffset, - ); - } - - lineOffset += countLines(output) + 1; - } - i++; - } - - assets += wrapped; - first = false; - }); - - let entries = this.bundle.getEntryAssets(); - let mainEntry = this.bundle.getMainEntry(); - if ( - (!this.isEntry() && this.bundle.env.outputFormat === 'global') || - this.bundle.env.sourceType === 'script' - ) { - // In async bundles we don't want the main entry to execute until we require it - // as there might be dependencies in a sibling bundle that hasn't loaded yet. - entries = entries.filter(a => a.id !== mainEntry?.id); - mainEntry = null; - } - - let contents = - prefix + - '({' + - assets + - '},' + - JSON.stringify( - entries.map(asset => this.bundleGraph.getAssetPublicId(asset)), - ) + - ', ' + - JSON.stringify( - mainEntry ? this.bundleGraph.getAssetPublicId(mainEntry) : null, - ) + - ', ' + - JSON.stringify(this.parcelRequireName) + - ')' + - '\n'; - - // The entry asset of a script bundle gets hoisted outside the bundle wrapper function - // so that its variables become globals. We need to replace any require calls for - // runtimes with a parcelRequire call. - if (this.bundle.env.sourceType === 'script' && script) { - let entryMap; - let mapBuffer = script.mapBuffer; - if (mapBuffer) { - entryMap = new SourceMap(this.options.projectRoot, mapBuffer); - } - contents += replaceScriptDependencies( - this.bundleGraph, - this.bundle, - script.code, - entryMap, - this.parcelRequireName, - ); - if (this.bundle.env.sourceMap && entryMap) { - map.addSourceMap(entryMap, lineOffset); - } - } - - return { - contents, - map, - }; - } - - getPrefix(): string { - let interpreter: ?string; - let mainEntry = this.bundle.getMainEntry(); - if (mainEntry && this.isEntry() && !this.bundle.target.env.isBrowser()) { - let _interpreter = mainEntry.meta.interpreter; - invariant(_interpreter == null || typeof _interpreter === 'string'); - interpreter = _interpreter; - } - - let importScripts = ''; - if (this.bundle.env.isWorker()) { - let bundles = this.bundleGraph.getReferencedBundles(this.bundle); - for (let b of bundles) { - importScripts += `importScripts("${relativeBundlePath( - this.bundle, - b, - )}");\n`; - } - } - - return ( - // If the entry asset included a hashbang, repeat it at the top of the bundle - (interpreter != null ? `#!${interpreter}\n` : '') + - importScripts + - PRELUDE - ); - } - - isEntry(): boolean { - return ( - !this.bundleGraph.hasParentBundleOfType(this.bundle, 'js') || - this.bundle.env.isIsolated() || - this.bundle.bundleBehavior === 'isolated' - ); - } -} diff --git a/packages/packagers/js/src/DevPackager.ts b/packages/packagers/js/src/DevPackager.ts new file mode 100644 index 000000000..677c3e90a --- /dev/null +++ b/packages/packagers/js/src/DevPackager.ts @@ -0,0 +1,265 @@ +import type {BundleGraph, PluginOptions, NamedBundle} from '@atlaspack/types'; + +import { + PromiseQueue, + relativeBundlePath, + countLines, + normalizeSeparators, +} from '@atlaspack/utils'; +import SourceMap from '@parcel/source-map'; +import invariant from 'assert'; +import path from 'path'; +import fs from 'fs'; +import {replaceScriptDependencies, getSpecifier} from './utils'; + +const PRELUDE = fs + .readFileSync(path.join(__dirname, 'dev-prelude.js'), 'utf8') + .trim() + .replace(/;$/, ''); + +export class DevPackager { + options: PluginOptions; + bundleGraph: BundleGraph; + bundle: NamedBundle; + parcelRequireName: string; + + constructor( + options: PluginOptions, + bundleGraph: BundleGraph, + bundle: NamedBundle, + parcelRequireName: string, + ) { + this.options = options; + this.bundleGraph = bundleGraph; + this.bundle = bundle; + this.parcelRequireName = parcelRequireName; + } + + async package(): Promise<{ + contents: string; + map: SourceMap | null | undefined; + }> { + // Load assets + let queue = new PromiseQueue({maxConcurrent: 32}); + this.bundle.traverseAssets((asset) => { + queue.add(async () => { + let [code, mapBuffer] = await Promise.all([ + asset.getCode(), + this.bundle.env.sourceMap && asset.getMapBuffer(), + ]); + return {code, mapBuffer}; + }); + }); + + let results = await queue.run(); + + let assets = ''; + let i = 0; + let first = true; + let map = new SourceMap(this.options.projectRoot); + + let prefix = this.getPrefix(); + let lineOffset = countLines(prefix); + let script: + | { + code: string; + mapBuffer: Buffer | null | undefined; + } + | null + | undefined = null; + + this.bundle.traverse((node) => { + let wrapped = first ? '' : ','; + + if (node.type === 'dependency') { + let resolved = this.bundleGraph.getResolvedAsset( + node.value, + this.bundle, + ); + if (resolved && resolved.type !== 'js') { + // if this is a reference to another javascript asset, we should not include + // its output, as its contents should already be loaded. + invariant(!this.bundle.hasAsset(resolved)); + wrapped += + JSON.stringify(this.bundleGraph.getAssetPublicId(resolved)) + + ':[function() {},{}]'; + } else { + return; + } + } + + if (node.type === 'asset') { + let asset = node.value; + invariant( + asset.type === 'js', + 'all assets in a js bundle must be js assets', + ); + + // If this is the main entry of a script rather than a module, we need to hoist it + // outside the bundle wrapper function so that its variables are exposed as globals. + if ( + this.bundle.env.sourceType === 'script' && + asset === this.bundle.getMainEntry() + ) { + // @ts-expect-error - TS2322 - Type 'unknown' is not assignable to type '{ code: string; mapBuffer: Buffer | null | undefined; } | null | undefined'. + script = results[i++]; + return; + } + + let deps: Record = {}; + let dependencies = this.bundleGraph.getDependencies(asset); + for (let dep of dependencies) { + let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); + let specifier = getSpecifier(dep); + if (this.bundleGraph.isDependencySkipped(dep)) { + deps[specifier] = false; + } else if (resolved) { + deps[specifier] = this.bundleGraph.getAssetPublicId(resolved); + } else { + // An external module - map placeholder to original specifier. + deps[specifier] = dep.specifier; + } + } + + // @ts-expect-error - TS2339 - Property 'code' does not exist on type 'unknown'. | TS2339 - Property 'mapBuffer' does not exist on type 'unknown'. + let {code, mapBuffer} = results[i]; + let output = code || ''; + wrapped += + JSON.stringify(this.bundleGraph.getAssetPublicId(asset)) + + ':[function(require,module,exports,__globalThis) {\n' + + output + + '\n},'; + wrapped += JSON.stringify(deps); + wrapped += ']'; + + if ( + this.bundle.env.isNode() && + asset.meta.has_node_replacements === true + ) { + const relPath = normalizeSeparators( + path.relative( + this.bundle.target.distDir, + path.dirname(asset.filePath), + ), + ); + wrapped = wrapped.replace('$parcel$dirnameReplace', relPath); + wrapped = wrapped.replace('$parcel$filenameReplace', relPath); + } + + if (this.bundle.env.sourceMap) { + if (mapBuffer) { + map.addBuffer(mapBuffer, lineOffset); + } else { + map.addEmptyMap( + path + .relative(this.options.projectRoot, asset.filePath) + .replace(/\\+/g, '/'), + output, + lineOffset, + ); + } + + lineOffset += countLines(output) + 1; + } + i++; + } + + assets += wrapped; + first = false; + }); + + let entries = this.bundle.getEntryAssets(); + let mainEntry = this.bundle.getMainEntry(); + if ( + (!this.isEntry() && this.bundle.env.outputFormat === 'global') || + this.bundle.env.sourceType === 'script' + ) { + // In async bundles we don't want the main entry to execute until we require it + // as there might be dependencies in a sibling bundle that hasn't loaded yet. + entries = entries.filter((a) => a.id !== mainEntry?.id); + mainEntry = null; + } + + let contents = + prefix + + '({' + + assets + + '},' + + JSON.stringify( + entries.map((asset) => this.bundleGraph.getAssetPublicId(asset)), + ) + + ', ' + + JSON.stringify( + mainEntry ? this.bundleGraph.getAssetPublicId(mainEntry) : null, + ) + + ', ' + + JSON.stringify(this.parcelRequireName) + + ')' + + '\n'; + + // The entry asset of a script bundle gets hoisted outside the bundle wrapper function + // so that its variables become globals. We need to replace any require calls for + // runtimes with a parcelRequire call. + if (this.bundle.env.sourceType === 'script' && script) { + let entryMap; + // @ts-expect-error - TS2339 - Property 'mapBuffer' does not exist on type 'never'. + let mapBuffer = script.mapBuffer; + if (mapBuffer) { + entryMap = new SourceMap(this.options.projectRoot, mapBuffer); + } + contents += replaceScriptDependencies( + this.bundleGraph, + this.bundle, + // @ts-expect-error - TS2339 - Property 'code' does not exist on type 'never'. + script.code, + entryMap, + this.parcelRequireName, + ); + if (this.bundle.env.sourceMap && entryMap) { + // @ts-expect-error - TS2551 - Property 'addSourceMap' does not exist on type 'SourceMap'. Did you mean 'addSources'? + map.addSourceMap(entryMap, lineOffset); + } + } + + return { + contents, + map, + }; + } + + getPrefix(): string { + let interpreter: string | null | undefined; + let mainEntry = this.bundle.getMainEntry(); + if (mainEntry && this.isEntry() && !this.bundle.target.env.isBrowser()) { + let _interpreter = mainEntry.meta.interpreter; + invariant(_interpreter == null || typeof _interpreter === 'string'); + interpreter = _interpreter; + } + + let importScripts = ''; + if (this.bundle.env.isWorker()) { + let bundles = this.bundleGraph.getReferencedBundles(this.bundle); + for (let b of bundles) { + importScripts += `importScripts("${relativeBundlePath( + this.bundle, + b, + )}");\n`; + } + } + + return ( + // If the entry asset included a hashbang, repeat it at the top of the bundle + (interpreter != null ? `#!${interpreter}\n` : '') + + importScripts + + PRELUDE + ); + } + + isEntry(): boolean { + return ( + !this.bundleGraph.hasParentBundleOfType(this.bundle, 'js') || + this.bundle.env.isIsolated() || + this.bundle.bundleBehavior === 'isolated' + ); + } +} diff --git a/packages/packagers/js/src/ESMOutputFormat.js b/packages/packagers/js/src/ESMOutputFormat.js deleted file mode 100644 index 16a7217e9..000000000 --- a/packages/packagers/js/src/ESMOutputFormat.js +++ /dev/null @@ -1,129 +0,0 @@ -// @flow -import type { - ScopeHoistingPackager, - OutputFormat, -} from './ScopeHoistingPackager'; -import {isValidIdentifier} from './utils'; - -export class ESMOutputFormat implements OutputFormat { - packager: ScopeHoistingPackager; - - constructor(packager: ScopeHoistingPackager) { - this.packager = packager; - } - - buildBundlePrelude(): [string, number] { - let res = ''; - let lines = 0; - for (let [source, specifiers] of this.packager.externals) { - let defaultSpecifier = null; - let namespaceSpecifier = null; - let namedSpecifiers = []; - for (let [imported, symbol] of specifiers) { - if (imported === 'default' /* || isCommonJS*/) { - defaultSpecifier = symbol; - } else if (imported === '*') { - namespaceSpecifier = `* as ${symbol}`; - } else { - let specifier = imported; - if (!isValidIdentifier(specifier)) { - specifier = JSON.stringify(specifier); - } - if (symbol !== imported) { - specifier += ` as ${symbol}`; - } - - namedSpecifiers.push(specifier); - } - } - - // ESModule syntax allows combining default and namespace specifiers, or default and named, but not all three. - - let imported = ''; - if (namespaceSpecifier) { - let s = namespaceSpecifier; - if (defaultSpecifier) { - s = `${defaultSpecifier}, ${namespaceSpecifier}`; - } - - res += `import ${s} from ${JSON.stringify(source)};\n`; - lines++; - } else if (defaultSpecifier) { - imported = defaultSpecifier; - if (namedSpecifiers.length > 0) { - imported += `, {${namedSpecifiers.join(', ')}}`; - } - } else if (namedSpecifiers.length > 0) { - imported = `{${namedSpecifiers.join(', ')}}`; - } - - if (imported.length > 0) { - res += `import ${imported} from ${JSON.stringify(source)};\n`; - lines++; - } else if (!namespaceSpecifier) { - res += `import ${JSON.stringify(source)};\n`; - lines++; - } - } - - if (res.length > 0) { - res += '\n'; - lines++; - } - - return [res, lines]; - } - - buildBundlePostlude(): [string, number] { - let res = ''; - let lines = 0; - let exportSpecifiers = []; - for (let { - asset, - exportSymbol, - local, - exportAs, - } of this.packager.exportedSymbols.values()) { - if (this.packager.wrappedAssets.has(asset.id)) { - let obj = `parcelRequire("${this.packager.bundleGraph.getAssetPublicId( - asset, - )}")`; - res += `\nvar ${local} = ${this.packager.getPropertyAccess( - obj, - exportSymbol, - )};`; - lines++; - } - - for (let as of exportAs) { - let specifier = local; - if (as !== local) { - if (!isValidIdentifier(as)) { - as = JSON.stringify(as); - } - specifier += ` as ${as}`; - } - - exportSpecifiers.push(specifier); - } - } - - if (exportSpecifiers.length > 0) { - res += `\nexport {${exportSpecifiers.join(', ')}};`; - lines++; - } - - if ( - this.packager.needsPrelude && - this.packager.shouldBundleQueue(this.packager.bundle) - ) { - // Should be last thing the bundle executes on intial eval - res += `\n$parcel$global.rlb(${JSON.stringify( - this.packager.bundle.publicId, - )})`; - lines++; - } - - return [res, lines]; - } -} diff --git a/packages/packagers/js/src/ESMOutputFormat.ts b/packages/packagers/js/src/ESMOutputFormat.ts new file mode 100644 index 000000000..8d17961a6 --- /dev/null +++ b/packages/packagers/js/src/ESMOutputFormat.ts @@ -0,0 +1,128 @@ +import type { + ScopeHoistingPackager, + OutputFormat, +} from './ScopeHoistingPackager'; +import {isValidIdentifier} from './utils'; + +export class ESMOutputFormat implements OutputFormat { + packager: ScopeHoistingPackager; + + constructor(packager: ScopeHoistingPackager) { + this.packager = packager; + } + + buildBundlePrelude(): [string, number] { + let res = ''; + let lines = 0; + for (let [source, specifiers] of this.packager.externals) { + let defaultSpecifier = null; + let namespaceSpecifier = null; + let namedSpecifiers: Array = []; + for (let [imported, symbol] of specifiers) { + if (imported === 'default' /* || isCommonJS*/) { + defaultSpecifier = symbol; + } else if (imported === '*') { + namespaceSpecifier = `* as ${symbol}`; + } else { + let specifier = imported; + if (!isValidIdentifier(specifier)) { + specifier = JSON.stringify(specifier); + } + if (symbol !== imported) { + specifier += ` as ${symbol}`; + } + + namedSpecifiers.push(specifier); + } + } + + // ESModule syntax allows combining default and namespace specifiers, or default and named, but not all three. + + let imported = ''; + if (namespaceSpecifier) { + let s = namespaceSpecifier; + if (defaultSpecifier) { + s = `${defaultSpecifier}, ${namespaceSpecifier}`; + } + + res += `import ${s} from ${JSON.stringify(source)};\n`; + lines++; + } else if (defaultSpecifier) { + imported = defaultSpecifier; + if (namedSpecifiers.length > 0) { + imported += `, {${namedSpecifiers.join(', ')}}`; + } + } else if (namedSpecifiers.length > 0) { + imported = `{${namedSpecifiers.join(', ')}}`; + } + + if (imported.length > 0) { + res += `import ${imported} from ${JSON.stringify(source)};\n`; + lines++; + } else if (!namespaceSpecifier) { + res += `import ${JSON.stringify(source)};\n`; + lines++; + } + } + + if (res.length > 0) { + res += '\n'; + lines++; + } + + return [res, lines]; + } + + buildBundlePostlude(): [string, number] { + let res = ''; + let lines = 0; + let exportSpecifiers: Array = []; + for (let { + asset, + exportSymbol, + local, + exportAs, + } of this.packager.exportedSymbols.values()) { + if (this.packager.wrappedAssets.has(asset.id)) { + let obj = `parcelRequire("${this.packager.bundleGraph.getAssetPublicId( + asset, + )}")`; + res += `\nvar ${local} = ${this.packager.getPropertyAccess( + obj, + exportSymbol, + )};`; + lines++; + } + + for (let as of exportAs) { + let specifier = local; + if (as !== local) { + if (!isValidIdentifier(as)) { + as = JSON.stringify(as); + } + specifier += ` as ${as}`; + } + + exportSpecifiers.push(specifier); + } + } + + if (exportSpecifiers.length > 0) { + res += `\nexport {${exportSpecifiers.join(', ')}};`; + lines++; + } + + if ( + this.packager.needsPrelude && + this.packager.shouldBundleQueue(this.packager.bundle) + ) { + // Should be last thing the bundle executes on intial eval + res += `\n$parcel$global.rlb(${JSON.stringify( + this.packager.bundle.publicId, + )})`; + lines++; + } + + return [res, lines]; + } +} diff --git a/packages/packagers/js/src/GlobalOutputFormat.js b/packages/packagers/js/src/GlobalOutputFormat.js deleted file mode 100644 index 68ac11281..000000000 --- a/packages/packagers/js/src/GlobalOutputFormat.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow -import type { - ScopeHoistingPackager, - OutputFormat, -} from './ScopeHoistingPackager'; - -export class GlobalOutputFormat implements OutputFormat { - packager: ScopeHoistingPackager; - - constructor(packager: ScopeHoistingPackager) { - this.packager = packager; - } - - buildBundlePrelude(): [string, number] { - let prelude = this.packager.bundle.env.supports('arrow-functions', true) - ? '(() => {\n' - : '(function () {\n'; - return [prelude, 1]; - } - - buildBundlePostlude(): [string, number] { - return ['})();', 0]; - } -} diff --git a/packages/packagers/js/src/GlobalOutputFormat.ts b/packages/packagers/js/src/GlobalOutputFormat.ts new file mode 100644 index 000000000..61bbbbf18 --- /dev/null +++ b/packages/packagers/js/src/GlobalOutputFormat.ts @@ -0,0 +1,23 @@ +import type { + ScopeHoistingPackager, + OutputFormat, +} from './ScopeHoistingPackager'; + +export class GlobalOutputFormat implements OutputFormat { + packager: ScopeHoistingPackager; + + constructor(packager: ScopeHoistingPackager) { + this.packager = packager; + } + + buildBundlePrelude(): [string, number] { + let prelude = this.packager.bundle.env.supports('arrow-functions', true) + ? '(() => {\n' + : '(function () {\n'; + return [prelude, 1]; + } + + buildBundlePostlude(): [string, number] { + return ['})();', 0]; + } +} diff --git a/packages/packagers/js/src/ScopeHoistingPackager.js b/packages/packagers/js/src/ScopeHoistingPackager.js deleted file mode 100644 index c0630dd7a..000000000 --- a/packages/packagers/js/src/ScopeHoistingPackager.js +++ /dev/null @@ -1,1503 +0,0 @@ -// @flow - -import type { - Asset, - BundleGraph, - Dependency, - PluginOptions, - NamedBundle, - PluginLogger, -} from '@atlaspack/types'; - -import { - DefaultMap, - PromiseQueue, - relativeBundlePath, - countLines, - normalizeSeparators, -} from '@atlaspack/utils'; -import SourceMap from '@parcel/source-map'; -import nullthrows from 'nullthrows'; -import invariant, {AssertionError} from 'assert'; -import ThrowableDiagnostic, { - convertSourceLocationToHighlight, -} from '@atlaspack/diagnostic'; -import globals from 'globals'; -import path from 'path'; -import {getFeatureFlag} from '@atlaspack/feature-flags'; - -import {ESMOutputFormat} from './ESMOutputFormat'; -import {CJSOutputFormat} from './CJSOutputFormat'; -import {GlobalOutputFormat} from './GlobalOutputFormat'; -import {prelude, helpers, bundleQueuePrelude, fnExpr} from './helpers'; -import { - replaceScriptDependencies, - getSpecifier, - isValidIdentifier, - makeValidIdentifier, -} from './utils'; - -// General regex used to replace imports with the resolved code, references with resolutions, -// and count the number of newlines in the file for source maps. -const REPLACEMENT_RE = - /\n|import\s+"([0-9a-f]{16,20}:.+?)";|(?:\$[0-9a-f]{16,20}\$exports)|(?:\$[0-9a-f]{16,20}\$(?:import|importAsync|require)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g; - -const BUILTINS = Object.keys(globals.builtin); -const GLOBALS_BY_CONTEXT = { - browser: new Set([...BUILTINS, ...Object.keys(globals.browser)]), - 'web-worker': new Set([...BUILTINS, ...Object.keys(globals.worker)]), - 'service-worker': new Set([ - ...BUILTINS, - ...Object.keys(globals.serviceworker), - ]), - worklet: new Set([...BUILTINS]), - node: new Set([...BUILTINS, ...Object.keys(globals.node)]), - 'electron-main': new Set([...BUILTINS, ...Object.keys(globals.node)]), - 'electron-renderer': new Set([ - ...BUILTINS, - ...Object.keys(globals.node), - ...Object.keys(globals.browser), - ]), -}; - -const OUTPUT_FORMATS = { - esmodule: ESMOutputFormat, - commonjs: CJSOutputFormat, - global: GlobalOutputFormat, -}; - -export interface OutputFormat { - buildBundlePrelude(): [string, number]; - buildBundlePostlude(): [string, number]; -} - -export class ScopeHoistingPackager { - options: PluginOptions; - bundleGraph: BundleGraph; - bundle: NamedBundle; - parcelRequireName: string; - useAsyncBundleRuntime: boolean; - outputFormat: OutputFormat; - isAsyncBundle: boolean; - globalNames: $ReadOnlySet; - assetOutputs: Map; - exportedSymbols: Map< - string, - {| - asset: Asset, - exportSymbol: string, - local: string, - exportAs: Array, - |}, - > = new Map(); - externals: Map> = new Map(); - topLevelNames: Map = new Map(); - seenAssets: Set = new Set(); - wrappedAssets: Set = new Set(); - hoistedRequires: Map> = new Map(); - needsPrelude: boolean = false; - usedHelpers: Set = new Set(); - externalAssets: Set = new Set(); - forceSkipWrapAssets: Array = []; - logger: PluginLogger; - - constructor( - options: PluginOptions, - bundleGraph: BundleGraph, - bundle: NamedBundle, - parcelRequireName: string, - useAsyncBundleRuntime: boolean, - forceSkipWrapAssets: Array, - logger: PluginLogger, - ) { - this.options = options; - this.bundleGraph = bundleGraph; - this.bundle = bundle; - this.parcelRequireName = parcelRequireName; - this.useAsyncBundleRuntime = useAsyncBundleRuntime; - this.forceSkipWrapAssets = forceSkipWrapAssets ?? []; - this.logger = logger; - - let OutputFormat = OUTPUT_FORMATS[this.bundle.env.outputFormat]; - this.outputFormat = new OutputFormat(this); - - this.isAsyncBundle = - this.bundleGraph.hasParentBundleOfType(this.bundle, 'js') && - !this.bundle.env.isIsolated() && - this.bundle.bundleBehavior !== 'isolated'; - - this.globalNames = GLOBALS_BY_CONTEXT[bundle.env.context]; - } - - async package(): Promise<{|contents: string, map: ?SourceMap|}> { - let wrappedAssets = await this.loadAssets(); - this.buildExportedSymbols(); - - // If building a library, the target is actually another bundler rather - // than the final output that could be loaded in a browser. So, loader - // runtimes are excluded, and instead we add imports into the entry bundle - // of each bundle group pointing at the sibling bundles. These can be - // picked up by another bundler later at which point runtimes will be added. - if ( - this.bundle.env.isLibrary || - this.bundle.env.outputFormat === 'commonjs' - ) { - for (let b of this.bundleGraph.getReferencedBundles(this.bundle, { - recursive: false, - })) { - this.externals.set(relativeBundlePath(this.bundle, b), new Map()); - } - } - - let res = ''; - let lineCount = 0; - let sourceMap = null; - let processAsset = asset => { - let [content, map, lines] = this.visitAsset(asset); - if (sourceMap && map) { - sourceMap.addSourceMap(map, lineCount); - } else if (this.bundle.env.sourceMap) { - sourceMap = map; - } - - res += content + '\n'; - lineCount += lines + 1; - }; - - // Hoist wrapped asset to the top of the bundle to ensure that they are registered - // before they are used. - for (let asset of wrappedAssets) { - if (!this.seenAssets.has(asset.id)) { - processAsset(asset); - } - } - - // Add each asset that is directly connected to the bundle. Dependencies will be handled - // by replacing `import` statements in the code. - this.bundle.traverseAssets((asset, _, actions) => { - if (this.seenAssets.has(asset.id)) { - actions.skipChildren(); - return; - } - - processAsset(asset); - actions.skipChildren(); - }); - - let [prelude, preludeLines] = this.buildBundlePrelude(); - res = prelude + res; - lineCount += preludeLines; - sourceMap?.offsetLines(1, preludeLines); - - let entries = this.bundle.getEntryAssets(); - let mainEntry = this.bundle.getMainEntry(); - if (this.isAsyncBundle) { - // In async bundles we don't want the main entry to execute until we require it - // as there might be dependencies in a sibling bundle that hasn't loaded yet. - entries = entries.filter(a => a.id !== mainEntry?.id); - mainEntry = null; - } - - let needsBundleQueue = this.shouldBundleQueue(this.bundle); - - // If any of the entry assets are wrapped, call parcelRequire so they are executed. - for (let entry of entries) { - if (this.wrappedAssets.has(entry.id) && !this.isScriptEntry(entry)) { - let parcelRequire = `parcelRequire(${JSON.stringify( - this.bundleGraph.getAssetPublicId(entry), - )});\n`; - - let entryExports = entry.symbols.get('*')?.local; - - if ( - entryExports && - entry === mainEntry && - this.exportedSymbols.has(entryExports) - ) { - invariant( - !needsBundleQueue, - 'Entry exports are not yet compaitble with async bundles', - ); - res += `\nvar ${entryExports} = ${parcelRequire}`; - } else { - if (needsBundleQueue) { - parcelRequire = this.runWhenReady(this.bundle, parcelRequire); - } - - res += `\n${parcelRequire}`; - } - - lineCount += 2; - } - } - - let [postlude, postludeLines] = this.outputFormat.buildBundlePostlude(); - res += postlude; - lineCount += postludeLines; - - // The entry asset of a script bundle gets hoisted outside the bundle wrapper so that - // its top-level variables become globals like a real browser script. We need to replace - // all dependency references for runtimes with a parcelRequire call. - if ( - this.bundle.env.outputFormat === 'global' && - this.bundle.env.sourceType === 'script' - ) { - res += '\n'; - lineCount++; - - let mainEntry = nullthrows(this.bundle.getMainEntry()); - let {code, map: mapBuffer} = nullthrows( - this.assetOutputs.get(mainEntry.id), - ); - let map; - if (mapBuffer) { - map = new SourceMap(this.options.projectRoot, mapBuffer); - } - res += replaceScriptDependencies( - this.bundleGraph, - this.bundle, - code, - map, - this.parcelRequireName, - ); - if (sourceMap && map) { - sourceMap.addSourceMap(map, lineCount); - } - } - - return { - contents: res, - map: sourceMap, - }; - } - - shouldBundleQueue(bundle: NamedBundle): boolean { - let referencingBundles = this.bundleGraph.getReferencingBundles(bundle); - let hasHtmlReference = referencingBundles.some(b => b.type === 'html'); - - return ( - this.useAsyncBundleRuntime && - bundle.type === 'js' && - bundle.bundleBehavior !== 'inline' && - bundle.env.outputFormat === 'esmodule' && - !bundle.env.isIsolated() && - bundle.bundleBehavior !== 'isolated' && - hasHtmlReference - ); - } - - runWhenReady(bundle: NamedBundle, codeToRun: string): string { - let deps = this.bundleGraph - .getReferencedBundles(bundle) - .filter(b => this.shouldBundleQueue(b)) - .map(b => b.publicId); - - if (deps.length === 0) { - // If no deps we can safely execute immediately - return codeToRun; - } - - let params = [ - JSON.stringify(this.bundle.publicId), - fnExpr(this.bundle.env, [], [codeToRun]), - JSON.stringify(deps), - ]; - - return `$parcel$global.rwr(${params.join(', ')});`; - } - - async loadAssets(): Promise> { - let queue = new PromiseQueue({maxConcurrent: 32}); - let wrapped = []; - this.bundle.traverseAssets(asset => { - queue.add(async () => { - let [code, map] = await Promise.all([ - asset.getCode(), - this.bundle.env.sourceMap ? asset.getMapBuffer() : null, - ]); - return [asset.id, {code, map}]; - }); - - if ( - asset.meta.shouldWrap || - this.bundle.env.sourceType === 'script' || - this.bundleGraph.isAssetReferenced(this.bundle, asset) || - this.bundleGraph - .getIncomingDependencies(asset) - .some(dep => dep.meta.shouldWrap && dep.specifierType !== 'url') - ) { - // Don't wrap constant "entry" modules _except_ if they are referenced by any lazy dependency - if ( - !asset.meta.isConstantModule || - this.bundleGraph - .getIncomingDependencies(asset) - .some(dep => dep.priority === 'lazy') - ) { - this.wrappedAssets.add(asset.id); - wrapped.push(asset); - } - } - }); - - for (let wrappedAssetRoot of [...wrapped]) { - this.bundle.traverseAssets((asset, _, actions) => { - if (asset === wrappedAssetRoot) { - return; - } - - if (this.wrappedAssets.has(asset.id)) { - actions.skipChildren(); - return; - } - // This prevents children of a wrapped asset also being wrapped - it's an "unsafe" optimisation - // that should only be used when you know (or think you know) what you're doing. - // - // In particular this can force an async bundle to be scope hoisted where it previously would not be - // due to the entry asset being wrapped. - if ( - this.forceSkipWrapAssets.length > 0 && - this.forceSkipWrapAssets.some( - p => p === path.relative(this.options.projectRoot, asset.filePath), - ) - ) { - this.logger.verbose({ - message: `Force skipping wrapping of ${path.relative( - this.options.projectRoot, - asset.filePath, - )}`, - }); - actions.skipChildren(); - return; - } - if (!asset.meta.isConstantModule) { - this.wrappedAssets.add(asset.id); - wrapped.push(asset); - } - }, wrappedAssetRoot); - } - - this.assetOutputs = new Map(await queue.run()); - return wrapped; - } - - buildExportedSymbols() { - if ( - !this.bundle.env.isLibrary || - this.bundle.env.outputFormat !== 'esmodule' - ) { - return; - } - - // TODO: handle ESM exports of wrapped entry assets... - let entry = this.bundle.getMainEntry(); - if (entry && !this.wrappedAssets.has(entry.id)) { - let hasNamespace = entry.symbols.hasExportSymbol('*'); - - for (let { - asset, - exportAs, - symbol, - exportSymbol, - } of this.bundleGraph.getExportedSymbols(entry)) { - if (typeof symbol === 'string') { - // If the module has a namespace (e.g. commonjs), and this is not an entry, only export the namespace - // as default, without individual exports. This mirrors the importing logic in addExternal, avoiding - // extra unused exports and potential for non-identifier export names. - if (hasNamespace && this.isAsyncBundle && exportAs !== '*') { - continue; - } - - let symbols = this.exportedSymbols.get( - symbol === '*' ? nullthrows(entry.symbols.get('*')?.local) : symbol, - )?.exportAs; - - if (!symbols) { - symbols = []; - this.exportedSymbols.set(symbol, { - asset, - exportSymbol, - local: symbol, - exportAs: symbols, - }); - } - - if (exportAs === '*') { - exportAs = 'default'; - } - - symbols.push(exportAs); - } else if (symbol === null) { - // TODO `meta.exportsIdentifier[exportSymbol]` should be exported - // let relativePath = relative(options.projectRoot, asset.filePath); - // throw getThrowableDiagnosticForNode( - // md`${relativePath} couldn't be statically analyzed when importing '${exportSymbol}'`, - // entry.filePath, - // loc, - // ); - } else if (symbol !== false) { - // let relativePath = relative(options.projectRoot, asset.filePath); - // throw getThrowableDiagnosticForNode( - // md`${relativePath} does not export '${exportSymbol}'`, - // entry.filePath, - // loc, - // ); - } - } - } - } - - getTopLevelName(name: string): string { - name = makeValidIdentifier(name); - if (this.globalNames.has(name)) { - name = '_' + name; - } - - let count = this.topLevelNames.get(name); - if (count == null) { - this.topLevelNames.set(name, 1); - return name; - } - - this.topLevelNames.set(name, count + 1); - return name + count; - } - - getPropertyAccess(obj: string, property: string): string { - if (isValidIdentifier(property)) { - return `${obj}.${property}`; - } - - return `${obj}[${JSON.stringify(property)}]`; - } - - visitAsset(asset: Asset): [string, ?SourceMap, number] { - invariant(!this.seenAssets.has(asset.id), 'Already visited asset'); - this.seenAssets.add(asset.id); - - let {code, map} = nullthrows(this.assetOutputs.get(asset.id)); - return this.buildAsset(asset, code, map); - } - - buildAsset( - asset: Asset, - code: string, - map: ?Buffer, - ): [string, ?SourceMap, number] { - let shouldWrap = this.wrappedAssets.has(asset.id); - let deps = this.bundleGraph.getDependencies(asset); - - let sourceMap = - this.bundle.env.sourceMap && map - ? new SourceMap(this.options.projectRoot, map) - : null; - - // If this asset is skipped, just add dependencies and not the asset's content. - if (this.shouldSkipAsset(asset)) { - let depCode = ''; - let lineCount = 0; - for (let dep of deps) { - let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); - let skipped = this.bundleGraph.isDependencySkipped(dep); - if (skipped) { - continue; - } - - if (!resolved) { - if (!dep.isOptional) { - this.addExternal(dep); - } - - continue; - } - - if ( - this.bundle.hasAsset(resolved) && - !this.seenAssets.has(resolved.id) - ) { - let [code, map, lines] = this.visitAsset(resolved); - depCode += code + '\n'; - if (sourceMap && map) { - sourceMap.addSourceMap(map, lineCount); - } - lineCount += lines + 1; - } - } - - return [depCode, sourceMap, lineCount]; - } - - // TODO: maybe a meta prop? - if (code.includes('$parcel$global')) { - this.usedHelpers.add('$parcel$global'); - } - - if (this.bundle.env.isNode() && asset.meta.has_node_replacements) { - const relPath = normalizeSeparators( - path.relative(this.bundle.target.distDir, path.dirname(asset.filePath)), - ); - code = code.replace('$parcel$dirnameReplace', relPath); - code = code.replace('$parcel$filenameReplace', relPath); - } - - let [depMap, replacements] = this.buildReplacements(asset, deps); - let [prepend, prependLines, append] = this.buildAssetPrelude( - asset, - deps, - replacements, - ); - if (prependLines > 0) { - sourceMap?.offsetLines(1, prependLines); - code = prepend + code; - } - - code += append; - - let lineCount = 0; - let depContent = []; - if (depMap.size === 0 && replacements.size === 0) { - // If there are no dependencies or replacements, use a simple function to count the number of lines. - lineCount = countLines(code) - 1; - } else { - // Otherwise, use a regular expression to perform replacements. - // We need to track how many newlines there are for source maps, replace - // all import statements with dependency code, and perform inline replacements - // of all imported symbols with their resolved export symbols. This is all done - // in a single regex so that we only do one pass over the whole code. - let offset = 0; - let columnStartIndex = 0; - code = code.replace(REPLACEMENT_RE, (m, d, i) => { - if (m === '\n') { - columnStartIndex = i + offset + 1; - lineCount++; - return '\n'; - } - - // If we matched an import, replace with the source code for the dependency. - if (d != null) { - let deps = depMap.get(d); - if (!deps) { - return m; - } - - let replacement = ''; - - // A single `${id}:${specifier}:esm` might have been resolved to multiple assets due to - // reexports. - for (let dep of deps) { - let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); - let skipped = this.bundleGraph.isDependencySkipped(dep); - if (resolved && !skipped) { - // Hoist variable declarations for the referenced parcelRequire dependencies - // after the dependency is declared. This handles the case where the resulting asset - // is wrapped, but the dependency in this asset is not marked as wrapped. This means - // that it was imported/required at the top-level, so its side effects should run immediately. - let [res, lines] = this.getHoistedParcelRequires( - asset, - dep, - resolved, - ); - let map; - if ( - this.bundle.hasAsset(resolved) && - !this.seenAssets.has(resolved.id) - ) { - // If this asset is wrapped, we need to hoist the code for the dependency - // outside our parcelRequire.register wrapper. This is safe because all - // assets referenced by this asset will also be wrapped. Otherwise, inline the - // asset content where the import statement was. - if (shouldWrap) { - depContent.push(this.visitAsset(resolved)); - } else { - let [depCode, depMap, depLines] = this.visitAsset(resolved); - res = depCode + '\n' + res; - lines += 1 + depLines; - map = depMap; - } - } - - // Push this asset's source mappings down by the number of lines in the dependency - // plus the number of hoisted parcelRequires. Then insert the source map for the dependency. - if (sourceMap) { - if (lines > 0) { - sourceMap.offsetLines(lineCount + 1, lines); - } - - if (map) { - sourceMap.addSourceMap(map, lineCount); - } - } - - replacement += res; - lineCount += lines; - } - } - return replacement; - } - - // If it wasn't a dependency, then it was an inline replacement (e.g. $id$import$foo -> $id$export$foo). - let replacement = replacements.get(m) ?? m; - if (sourceMap) { - // Offset the source map columns for this line if the replacement was a different length. - // This assumes that the match and replacement both do not contain any newlines. - let lengthDifference = replacement.length - m.length; - if (lengthDifference !== 0) { - sourceMap.offsetColumns( - lineCount + 1, - i + offset - columnStartIndex + m.length, - lengthDifference, - ); - offset += lengthDifference; - } - } - return replacement; - }); - } - - // If the asset is wrapped, we need to insert the dependency code outside the parcelRequire.register - // wrapper. Dependencies must be inserted AFTER the asset is registered so that circular dependencies work. - if (shouldWrap) { - // Offset by one line for the parcelRequire.register wrapper. - sourceMap?.offsetLines(1, 1); - lineCount++; - - code = `parcelRegister(${JSON.stringify( - this.bundleGraph.getAssetPublicId(asset), - )}, function(module, exports) { -${code} -}); -`; - - lineCount += 2; - - for (let [depCode, map, lines] of depContent) { - if (!depCode) continue; - code += depCode + '\n'; - if (sourceMap && map) { - sourceMap.addSourceMap(map, lineCount); - } - lineCount += lines + 1; - } - - this.needsPrelude = true; - } - - if ( - !shouldWrap && - this.shouldBundleQueue(this.bundle) && - this.bundle.getEntryAssets().some(entry => entry.id === asset.id) - ) { - code = this.runWhenReady(this.bundle, code); - } - - return [code, sourceMap, lineCount]; - } - - buildReplacements( - asset: Asset, - deps: Array, - ): [Map>, Map] { - let assetId = asset.meta.id; - invariant(typeof assetId === 'string'); - - // Build two maps: one of import specifiers, and one of imported symbols to replace. - // These will be used to build a regex below. - let depMap = new DefaultMap>(() => []); - let replacements = new Map(); - for (let dep of deps) { - let specifierType = - dep.specifierType === 'esm' ? `:${dep.specifierType}` : ''; - depMap - .get( - `${assetId}:${getSpecifier(dep)}${ - !dep.meta.placeholder ? specifierType : '' - }`, - ) - .push(dep); - - let asyncResolution = this.bundleGraph.resolveAsyncDependency( - dep, - this.bundle, - ); - let resolved = - asyncResolution?.type === 'asset' - ? // Prefer the underlying asset over a runtime to load it. It will - // be wrapped in Promise.resolve() later. - asyncResolution.value - : this.bundleGraph.getResolvedAsset(dep, this.bundle); - if ( - !resolved && - !dep.isOptional && - !this.bundleGraph.isDependencySkipped(dep) - ) { - this.addExternal(dep, replacements); - } - - if (!resolved) { - continue; - } - - // Handle imports from other bundles in libraries. - if (this.bundle.env.isLibrary && !this.bundle.hasAsset(resolved)) { - let referencedBundle = this.bundleGraph.getReferencedBundle( - dep, - this.bundle, - ); - if ( - referencedBundle && - referencedBundle.getMainEntry() === resolved && - referencedBundle.type === 'js' && - !this.bundleGraph.isAssetReferenced(referencedBundle, resolved) - ) { - this.addExternal(dep, replacements, referencedBundle); - this.externalAssets.add(resolved); - continue; - } - } - - for (let [imported, {local}] of dep.symbols) { - if (local === '*') { - continue; - } - - let symbol = this.getSymbolResolution(asset, resolved, imported, dep); - replacements.set( - local, - // If this was an internalized async asset, wrap in a Promise.resolve. - asyncResolution?.type === 'asset' - ? `Promise.resolve(${symbol})` - : symbol, - ); - } - - // Async dependencies need a namespace object even if all used symbols were statically analyzed. - // This is recorded in the promiseSymbol meta property set by the transformer rather than in - // symbols so that we don't mark all symbols as used. - if (dep.priority === 'lazy' && dep.meta.promiseSymbol) { - let promiseSymbol = dep.meta.promiseSymbol; - invariant(typeof promiseSymbol === 'string'); - let symbol = this.getSymbolResolution(asset, resolved, '*', dep); - replacements.set( - promiseSymbol, - asyncResolution?.type === 'asset' - ? `Promise.resolve(${symbol})` - : symbol, - ); - } - } - - // If this asset is wrapped, we need to replace the exports namespace with `module.exports`, - // which will be provided to us by the wrapper. - if ( - this.wrappedAssets.has(asset.id) || - (this.bundle.env.outputFormat === 'commonjs' && - asset === this.bundle.getMainEntry()) - ) { - let exportsName = asset.symbols.get('*')?.local || `$${assetId}$exports`; - replacements.set(exportsName, 'module.exports'); - } - - return [depMap, replacements]; - } - - addExternal( - dep: Dependency, - replacements?: Map, - referencedBundle?: NamedBundle, - ) { - if (this.bundle.env.outputFormat === 'global') { - throw new ThrowableDiagnostic({ - diagnostic: { - message: - 'External modules are not supported when building for browser', - codeFrames: [ - { - filePath: nullthrows(dep.sourcePath), - codeHighlights: dep.loc - ? [convertSourceLocationToHighlight(dep.loc)] - : [], - }, - ], - }, - }); - } - - let specifier = dep.specifier; - if (referencedBundle) { - specifier = relativeBundlePath(this.bundle, referencedBundle); - } - - // Map of DependencySpecifier -> Map> - let external = this.externals.get(specifier); - if (!external) { - external = new Map(); - this.externals.set(specifier, external); - } - - for (let [imported, {local}] of dep.symbols) { - // If already imported, just add the already renamed variable to the mapping. - let renamed = external.get(imported); - if (renamed && local !== '*' && replacements) { - replacements.set(local, renamed); - continue; - } - - // For CJS output, always use a property lookup so that exports remain live. - // For ESM output, use named imports which are always live. - if (this.bundle.env.outputFormat === 'commonjs') { - renamed = external.get('*'); - if (!renamed) { - if (referencedBundle) { - let entry = nullthrows(referencedBundle.getMainEntry()); - renamed = - entry.symbols.get('*')?.local ?? - `$${String(entry.meta.id)}$exports`; - } else { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${specifier}`, - ); - } - - external.set('*', renamed); - } - - if (local !== '*' && replacements) { - let replacement; - if (imported === '*') { - replacement = renamed; - } else if (imported === 'default') { - let needsDefaultInterop = true; - if (referencedBundle) { - let entry = nullthrows(referencedBundle.getMainEntry()); - needsDefaultInterop = this.needsDefaultInterop(entry); - } - if (needsDefaultInterop) { - replacement = `($parcel$interopDefault(${renamed}))`; - this.usedHelpers.add('$parcel$interopDefault'); - } else { - replacement = `${renamed}.default`; - } - } else { - replacement = this.getPropertyAccess(renamed, imported); - } - - replacements.set(local, replacement); - } - } else { - let property; - if (referencedBundle) { - let entry = nullthrows(referencedBundle.getMainEntry()); - if (entry.symbols.hasExportSymbol('*')) { - // If importing * and the referenced module has a * export (e.g. CJS), use default instead. - // This mirrors the logic in buildExportedSymbols. - property = imported; - imported = - referencedBundle?.env.outputFormat === 'esmodule' - ? 'default' - : '*'; - } else { - if (imported === '*') { - let exportedSymbols = this.bundleGraph.getExportedSymbols(entry); - if (local === '*') { - // Re-export all symbols. - for (let exported of exportedSymbols) { - if (exported.symbol) { - external.set(exported.exportSymbol, exported.symbol); - } - } - continue; - } - } - renamed = this.bundleGraph.getSymbolResolution( - entry, - imported, - this.bundle, - ).symbol; - } - } - - // Rename the specifier so that multiple local imports of the same imported specifier - // are deduplicated. We have to prefix the imported name with the bundle id so that - // local variables do not shadow it. - if (!renamed) { - if (this.exportedSymbols.has(local)) { - renamed = local; - } else if (imported === 'default' || imported === '*') { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${specifier}`, - ); - } else { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${imported}`, - ); - } - } - - external.set(imported, renamed); - if (local !== '*' && replacements) { - let replacement = renamed; - if (property === '*') { - replacement = renamed; - } else if (property === 'default') { - replacement = `($parcel$interopDefault(${renamed}))`; - this.usedHelpers.add('$parcel$interopDefault'); - } else if (property) { - replacement = this.getPropertyAccess(renamed, property); - } - replacements.set(local, replacement); - } - } - } - } - - isWrapped(resolved: Asset, parentAsset: Asset): boolean { - if (resolved.meta.isConstantModule) { - if (!this.bundle.hasAsset(resolved)) { - throw new AssertionError({ - message: `Constant module ${path.relative( - this.options.projectRoot, - resolved.filePath, - )} referenced from ${path.relative( - this.options.projectRoot, - parentAsset.filePath, - )} not found in bundle ${this.bundle.name}`, - }); - } - return false; - } - return ( - (!this.bundle.hasAsset(resolved) && !this.externalAssets.has(resolved)) || - (this.wrappedAssets.has(resolved.id) && resolved !== parentAsset) - ); - } - - getSymbolResolution( - parentAsset: Asset, - resolved: Asset, - imported: string, - dep?: Dependency, - replacements?: Map, - ): string { - let { - asset: resolvedAsset, - exportSymbol, - symbol, - } = this.bundleGraph.getSymbolResolution(resolved, imported, this.bundle); - - if ( - resolvedAsset.type !== 'js' || - (dep && this.bundleGraph.isDependencySkipped(dep)) - ) { - // Graceful fallback for non-js imports or when trying to resolve a symbol - // that is actually unused but we still need a placeholder value. - return '{}'; - } - - let isWrapped = this.isWrapped(resolvedAsset, parentAsset); - let staticExports = resolvedAsset.meta.staticExports !== false; - let publicId = this.bundleGraph.getAssetPublicId(resolvedAsset); - - // External CommonJS dependencies need to be accessed as an object property rather than imported - // directly to maintain live binding. - let isExternalCommonJS = - !isWrapped && - this.bundle.env.isLibrary && - this.bundle.env.outputFormat === 'commonjs' && - !this.bundle.hasAsset(resolvedAsset); - - // If the resolved asset is wrapped, but imported at the top-level by this asset, - // then we hoist parcelRequire calls to the top of this asset so side effects run immediately. - if ( - isWrapped && - dep && - !dep?.meta.shouldWrap && - symbol !== false && - // Only do this if the asset is part of a different bundle (so it was definitely - // parcelRequire.register'ed there), or if it is indeed registered in this bundle. - (!this.bundle.hasAsset(resolvedAsset) || - !this.shouldSkipAsset(resolvedAsset)) - ) { - let hoisted = this.hoistedRequires.get(dep.id); - if (!hoisted) { - hoisted = new Map(); - this.hoistedRequires.set(dep.id, hoisted); - } - - hoisted.set( - resolvedAsset.id, - `var $${publicId} = parcelRequire(${JSON.stringify(publicId)});`, - ); - } - - if (isWrapped) { - this.needsPrelude = true; - } - - // If this is an ESM default import of a CJS module with a `default` symbol, - // and no __esModule flag, we need to resolve to the namespace instead. - let isDefaultInterop = - exportSymbol === 'default' && - staticExports && - !isWrapped && - (dep?.meta.kind === 'Import' || dep?.meta.kind === 'Export') && - resolvedAsset.symbols.hasExportSymbol('*') && - resolvedAsset.symbols.hasExportSymbol('default') && - !resolvedAsset.symbols.hasExportSymbol('__esModule'); - - // Find the namespace object for the resolved module. If wrapped and this - // is an inline require (not top-level), use a parcelRequire call, otherwise - // the hoisted variable declared above. Otherwise, if not wrapped, use the - // namespace export symbol. - let assetId = resolvedAsset.meta.id; - invariant(typeof assetId === 'string'); - let obj; - if (isWrapped && (!dep || dep?.meta.shouldWrap)) { - // Wrap in extra parenthesis to not change semantics, e.g.`new (parcelRequire("..."))()`. - obj = `(parcelRequire(${JSON.stringify(publicId)}))`; - } else if (isWrapped && dep) { - obj = `$${publicId}`; - } else { - obj = resolvedAsset.symbols.get('*')?.local || `$${assetId}$exports`; - obj = replacements?.get(obj) || obj; - } - - if (imported === '*' || exportSymbol === '*' || isDefaultInterop) { - // Resolve to the namespace object if requested or this is a CJS default interop reqiure. - if ( - parentAsset === resolvedAsset && - this.wrappedAssets.has(resolvedAsset.id) - ) { - // Directly use module.exports for wrapped assets importing themselves. - return 'module.exports'; - } else { - return obj; - } - } else if ( - (!staticExports || isWrapped || !symbol || isExternalCommonJS) && - resolvedAsset !== parentAsset - ) { - // If the resolved asset is wrapped or has non-static exports, - // we need to use a member access off the namespace object rather - // than a direct reference. If importing default from a CJS module, - // use a helper to check the __esModule flag at runtime. - let kind = dep?.meta.kind; - if ( - (!dep || kind === 'Import' || kind === 'Export') && - exportSymbol === 'default' && - resolvedAsset.symbols.hasExportSymbol('*') && - this.needsDefaultInterop(resolvedAsset) - ) { - this.usedHelpers.add('$parcel$interopDefault'); - return `(/*@__PURE__*/$parcel$interopDefault(${obj}))`; - } else { - return this.getPropertyAccess(obj, exportSymbol); - } - } else if (!symbol) { - invariant(false, 'Asset was skipped or not found.'); - } else { - return replacements?.get(symbol) || symbol; - } - } - - getHoistedParcelRequires( - parentAsset: Asset, - dep: Dependency, - resolved: Asset, - ): [string, number] { - if (resolved.type !== 'js') { - return ['', 0]; - } - - let hoisted = this.hoistedRequires.get(dep.id); - let res = ''; - let lineCount = 0; - let isWrapped = this.isWrapped(resolved, parentAsset); - - // If the resolved asset is wrapped and is imported in the top-level by this asset, - // we need to run side effects when this asset runs. If the resolved asset is not - // the first one in the hoisted requires, we need to insert a parcelRequire here - // so it runs first. - if ( - isWrapped && - !dep.meta.shouldWrap && - (!hoisted || hoisted.keys().next().value !== resolved.id) && - !this.bundleGraph.isDependencySkipped(dep) && - !this.shouldSkipAsset(resolved) - ) { - this.needsPrelude = true; - res += `parcelRequire(${JSON.stringify( - this.bundleGraph.getAssetPublicId(resolved), - )});`; - } - - if (hoisted) { - this.needsPrelude = true; - res += '\n' + [...hoisted.values()].join('\n'); - lineCount += hoisted.size; - } - - return [res, lineCount]; - } - - buildAssetPrelude( - asset: Asset, - deps: Array, - replacements: Map, - ): [string, number, string] { - let prepend = ''; - let prependLineCount = 0; - let append = ''; - - let shouldWrap = this.wrappedAssets.has(asset.id); - let usedSymbols = nullthrows(this.bundleGraph.getUsedSymbols(asset)); - let assetId = asset.meta.id; - invariant(typeof assetId === 'string'); - - // If the asset has a namespace export symbol, it is CommonJS. - // If there's no __esModule flag, and default is a used symbol, we need - // to insert an interop helper. - let defaultInterop = - asset.symbols.hasExportSymbol('*') && - usedSymbols.has('default') && - !asset.symbols.hasExportSymbol('__esModule'); - - let usedNamespace = - // If the asset has * in its used symbols, we might need the exports namespace. - // The one case where this isn't true is in ESM library entries, where the only - // dependency on * is the entry dependency. In this case, we will use ESM exports - // instead of the namespace object. - (usedSymbols.has('*') && - (this.bundle.env.outputFormat !== 'esmodule' || - !this.bundle.env.isLibrary || - asset !== this.bundle.getMainEntry() || - this.bundleGraph - .getIncomingDependencies(asset) - .some( - dep => - !dep.isEntry && - this.bundle.hasDependency(dep) && - nullthrows(this.bundleGraph.getUsedSymbols(dep)).has('*'), - ))) || - // If a symbol is imported (used) from a CJS asset but isn't listed in the symbols, - // we fallback on the namespace object. - (asset.symbols.hasExportSymbol('*') && - [...usedSymbols].some(s => !asset.symbols.hasExportSymbol(s))) || - // If the exports has this asset's namespace (e.g. ESM output from CJS input), - // include the namespace object for the default export. - this.exportedSymbols.has(`$${assetId}$exports`) || - // CommonJS library bundle entries always need a namespace. - (this.bundle.env.isLibrary && - this.bundle.env.outputFormat === 'commonjs' && - asset === this.bundle.getMainEntry()); - - // If the asset doesn't have static exports, should wrap, the namespace is used, - // or we need default interop, then we need to synthesize a namespace object for - // this asset. - if ( - asset.meta.staticExports === false || - shouldWrap || - usedNamespace || - defaultInterop - ) { - // Insert a declaration for the exports namespace object. If the asset is wrapped - // we don't need to do this, because we'll use the `module.exports` object provided - // by the wrapper instead. This is also true of CommonJS entry assets, which will use - // the `module.exports` object provided by CJS. - if ( - !shouldWrap && - (this.bundle.env.outputFormat !== 'commonjs' || - asset !== this.bundle.getMainEntry()) - ) { - prepend += `var $${assetId}$exports = {};\n`; - prependLineCount++; - } - - // Insert the __esModule interop flag for this module if it has a `default` export - // and the namespace symbol is used. - // TODO: only if required by CJS? - if (asset.symbols.hasExportSymbol('default') && usedSymbols.has('*')) { - prepend += `\n$parcel$defineInteropFlag($${assetId}$exports);\n`; - prependLineCount += 2; - this.usedHelpers.add('$parcel$defineInteropFlag'); - } - - // Find wildcard re-export dependencies, and make sure their exports are also included in - // ours. Importantly, add them before the asset's own exports so that wildcard exports get - // correctly overwritten by own exports of the same name. - for (let dep of deps) { - let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); - if (dep.isOptional || this.bundleGraph.isDependencySkipped(dep)) { - continue; - } - - let isWrapped = resolved && resolved.meta.shouldWrap; - - for (let [imported, {local}] of dep.symbols) { - if (imported === '*' && local === '*') { - if (!resolved) { - // Re-exporting an external module. This should have already been handled in buildReplacements. - let external = nullthrows( - nullthrows(this.externals.get(dep.specifier)).get('*'), - ); - append += `$parcel$exportWildcard($${assetId}$exports, ${external});\n`; - this.usedHelpers.add('$parcel$exportWildcard'); - continue; - } - - // If the resolved asset has an exports object, use the $parcel$exportWildcard helper - // to re-export all symbols. Otherwise, if there's no namespace object available, add - // $parcel$export calls for each used symbol of the dependency. - if ( - isWrapped || - resolved.meta.staticExports === false || - nullthrows(this.bundleGraph.getUsedSymbols(resolved)).has('*') || - // an empty asset - (!resolved.meta.hasCJSExports && - resolved.symbols.hasExportSymbol('*')) - ) { - let obj = this.getSymbolResolution( - asset, - resolved, - '*', - dep, - replacements, - ); - append += `$parcel$exportWildcard($${assetId}$exports, ${obj});\n`; - this.usedHelpers.add('$parcel$exportWildcard'); - } else { - for (let symbol of nullthrows( - this.bundleGraph.getUsedSymbols(dep), - )) { - if ( - symbol === 'default' || // `export * as ...` does not include the default export - symbol === '__esModule' - ) { - continue; - } - - let resolvedSymbol = this.getSymbolResolution( - asset, - resolved, - symbol, - undefined, - replacements, - ); - let get = this.buildFunctionExpression([], resolvedSymbol); - let set = asset.meta.hasCJSExports - ? ', ' + - this.buildFunctionExpression(['v'], `${resolvedSymbol} = v`) - : ''; - prepend += `$parcel$export($${assetId}$exports, ${JSON.stringify( - symbol, - )}, ${get}${set});\n`; - this.usedHelpers.add('$parcel$export'); - prependLineCount++; - } - } - } - } - } - - // Find the used exports of this module. This is based on the used symbols of - // incoming dependencies rather than the asset's own used exports so that we include - // re-exported symbols rather than only symbols declared in this asset. - let incomingDeps = this.bundleGraph.getIncomingDependencies(asset); - let usedExports = [...asset.symbols.exportSymbols()].filter(symbol => { - if (symbol === '*') { - return false; - } - - // If we need default interop, then all symbols are needed because the `default` - // symbol really maps to the whole namespace. - if (defaultInterop) { - return true; - } - - let unused = incomingDeps.every(d => { - let symbols = nullthrows(this.bundleGraph.getUsedSymbols(d)); - return !symbols.has(symbol) && !symbols.has('*'); - }); - return !unused; - }); - - if (usedExports.length > 0) { - // Insert $parcel$export calls for each of the used exports. This creates a getter/setter - // for the symbol so that when the value changes the object property also changes. This is - // required to simulate ESM live bindings. It's easier to do it this way rather than inserting - // additional assignments after each mutation of the original binding. - prepend += `\n${usedExports - .map(exp => { - let resolved = this.getSymbolResolution( - asset, - asset, - exp, - undefined, - replacements, - ); - let get = this.buildFunctionExpression([], resolved); - let isEsmExport = !!asset.symbols.get(exp)?.meta?.isEsm; - let set = - !isEsmExport && asset.meta.hasCJSExports - ? ', ' + this.buildFunctionExpression(['v'], `${resolved} = v`) - : ''; - return `$parcel$export($${assetId}$exports, ${JSON.stringify( - exp, - )}, ${get}${set});`; - }) - .join('\n')}\n`; - this.usedHelpers.add('$parcel$export'); - prependLineCount += 1 + usedExports.length; - } - } - - return [prepend, prependLineCount, append]; - } - - buildBundlePrelude(): [string, number] { - let enableSourceMaps = this.bundle.env.sourceMap; - let res = ''; - let lines = 0; - - // Add hashbang if the entry asset recorded an interpreter. - let mainEntry = this.bundle.getMainEntry(); - if ( - mainEntry && - !this.isAsyncBundle && - !this.bundle.target.env.isBrowser() - ) { - let interpreter = mainEntry.meta.interpreter; - invariant(interpreter == null || typeof interpreter === 'string'); - if (interpreter != null) { - res += `#!${interpreter}\n`; - lines++; - } - } - - // The output format may have specific things to add at the start of the bundle (e.g. imports). - let [outputFormatPrelude, outputFormatLines] = - this.outputFormat.buildBundlePrelude(); - res += outputFormatPrelude; - lines += outputFormatLines; - - // Add used helpers. - if (this.needsPrelude) { - this.usedHelpers.add('$parcel$global'); - } - - for (let helper of this.usedHelpers) { - let currentHelper = helpers[helper]; - if (typeof currentHelper === 'function') { - currentHelper = helpers[helper](this.bundle.env); - } - res += currentHelper; - if (enableSourceMaps) { - lines += countLines(currentHelper) - 1; - } - } - - if (this.needsPrelude) { - // Add the prelude if this is potentially the first JS bundle to load in a - // particular context (e.g. entry scripts in HTML, workers, etc.). - let parentBundles = this.bundleGraph.getParentBundles(this.bundle); - let mightBeFirstJS = - parentBundles.length === 0 || - parentBundles.some(b => b.type !== 'js') || - this.bundleGraph - .getBundleGroupsContainingBundle(this.bundle) - .some(g => this.bundleGraph.isEntryBundleGroup(g)) || - this.bundle.env.isIsolated() || - this.bundle.bundleBehavior === 'isolated'; - - if (mightBeFirstJS) { - let preludeCode = prelude(this.parcelRequireName); - res += preludeCode; - if (enableSourceMaps) { - lines += countLines(preludeCode) - 1; - } - - if (this.shouldBundleQueue(this.bundle)) { - let bundleQueuePreludeCode = bundleQueuePrelude(this.bundle.env); - res += bundleQueuePreludeCode; - if (enableSourceMaps) { - lines += countLines(bundleQueuePreludeCode) - 1; - } - } - } else { - // Otherwise, get the current parcelRequire global. - const escaped = JSON.stringify(this.parcelRequireName); - res += `var parcelRequire = $parcel$global[${escaped}];\n`; - lines++; - res += `var parcelRegister = parcelRequire.register;\n`; - lines++; - } - } - - // Add importScripts for sibling bundles in workers. - if (this.bundle.env.isWorker() || this.bundle.env.isWorklet()) { - let importScripts = ''; - let bundles = this.bundleGraph.getReferencedBundles(this.bundle); - for (let b of bundles) { - if (this.bundle.env.outputFormat === 'esmodule') { - // importScripts() is not allowed in native ES module workers. - importScripts += `import "${relativeBundlePath(this.bundle, b)}";\n`; - } else { - importScripts += `importScripts("${relativeBundlePath( - this.bundle, - b, - )}");\n`; - } - } - - res += importScripts; - lines += bundles.length; - } - - return [res, lines]; - } - - needsDefaultInterop(asset: Asset): boolean { - if ( - asset.symbols.hasExportSymbol('*') && - !asset.symbols.hasExportSymbol('default') - ) { - if (getFeatureFlag('fastNeedsDefaultInterop')) { - return true; - } - - let deps = this.bundleGraph.getIncomingDependencies(asset); - return deps.some( - dep => - this.bundle.hasDependency(dep) && - // dep.meta.isES6Module && - dep.symbols.hasExportSymbol('default'), - ); - } - - return false; - } - - shouldSkipAsset(asset: Asset): boolean { - if (this.isScriptEntry(asset)) { - return true; - } - - return ( - asset.sideEffects === false && - nullthrows(this.bundleGraph.getUsedSymbols(asset)).size == 0 && - !this.bundleGraph.isAssetReferenced(this.bundle, asset) - ); - } - - isScriptEntry(asset: Asset): boolean { - return ( - this.bundle.env.outputFormat === 'global' && - this.bundle.env.sourceType === 'script' && - asset === this.bundle.getMainEntry() - ); - } - - buildFunctionExpression(args: Array, expr: string): string { - return this.bundle.env.supports('arrow-functions', true) - ? `(${args.join(', ')}) => ${expr}` - : `function (${args.join(', ')}) { return ${expr}; }`; - } -} diff --git a/packages/packagers/js/src/ScopeHoistingPackager.ts b/packages/packagers/js/src/ScopeHoistingPackager.ts new file mode 100644 index 000000000..bc53c09f2 --- /dev/null +++ b/packages/packagers/js/src/ScopeHoistingPackager.ts @@ -0,0 +1,1583 @@ +import type { + Asset, + BundleGraph, + Dependency, + PluginOptions, + NamedBundle, + PluginLogger, +} from '@atlaspack/types'; + +import { + DefaultMap, + PromiseQueue, + relativeBundlePath, + countLines, + normalizeSeparators, +} from '@atlaspack/utils'; +import SourceMap from '@parcel/source-map'; +import nullthrows from 'nullthrows'; +import invariant, {AssertionError} from 'assert'; +import ThrowableDiagnostic, { + convertSourceLocationToHighlight, +} from '@atlaspack/diagnostic'; +import globals from 'globals'; +import path from 'path'; +import {getFeatureFlag} from '@atlaspack/feature-flags'; + +import {ESMOutputFormat} from './ESMOutputFormat'; +import {CJSOutputFormat} from './CJSOutputFormat'; +import {GlobalOutputFormat} from './GlobalOutputFormat'; +import {prelude, helpers, bundleQueuePrelude, fnExpr} from './helpers'; +import { + replaceScriptDependencies, + getSpecifier, + isValidIdentifier, + makeValidIdentifier, +} from './utils'; + +// General regex used to replace imports with the resolved code, references with resolutions, +// and count the number of newlines in the file for source maps. +const REPLACEMENT_RE = + /\n|import\s+"([0-9a-f]{16,20}:.+?)";|(?:\$[0-9a-f]{16,20}\$exports)|(?:\$[0-9a-f]{16,20}\$(?:import|importAsync|require)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g; + +const BUILTINS = Object.keys(globals.builtin); +const GLOBALS_BY_CONTEXT = { + browser: new Set([...BUILTINS, ...Object.keys(globals.browser)]), + 'web-worker': new Set([...BUILTINS, ...Object.keys(globals.worker)]), + 'service-worker': new Set([ + ...BUILTINS, + ...Object.keys(globals.serviceworker), + ]), + worklet: new Set([...BUILTINS]), + node: new Set([...BUILTINS, ...Object.keys(globals.node)]), + 'electron-main': new Set([...BUILTINS, ...Object.keys(globals.node)]), + 'electron-renderer': new Set([ + ...BUILTINS, + ...Object.keys(globals.node), + ...Object.keys(globals.browser), + ]), +} as const; + +const OUTPUT_FORMATS = { + esmodule: ESMOutputFormat, + commonjs: CJSOutputFormat, + global: GlobalOutputFormat, +} as const; + +export interface OutputFormat { + buildBundlePrelude(): [string, number]; + buildBundlePostlude(): [string, number]; +} + +export class ScopeHoistingPackager { + options: PluginOptions; + bundleGraph: BundleGraph; + bundle: NamedBundle; + parcelRequireName: string; + useAsyncBundleRuntime: boolean; + outputFormat: OutputFormat; + isAsyncBundle: boolean; + globalNames: $ReadOnlySet; + // @ts-expect-error - TS2564 - Property 'assetOutputs' has no initializer and is not definitely assigned in the constructor. + assetOutputs: Map< + string, + { + code: string; + map: Buffer | null | undefined; + } + >; + exportedSymbols: Map< + string, + { + asset: Asset; + exportSymbol: string; + local: string; + exportAs: Array; + } + > = new Map(); + externals: Map> = new Map(); + topLevelNames: Map = new Map(); + seenAssets: Set = new Set(); + wrappedAssets: Set = new Set(); + hoistedRequires: Map> = new Map(); + needsPrelude: boolean = false; + usedHelpers: Set = new Set(); + externalAssets: Set = new Set(); + forceSkipWrapAssets: Array = []; + logger: PluginLogger; + + constructor( + options: PluginOptions, + bundleGraph: BundleGraph, + bundle: NamedBundle, + parcelRequireName: string, + useAsyncBundleRuntime: boolean, + forceSkipWrapAssets: Array, + logger: PluginLogger, + ) { + this.options = options; + this.bundleGraph = bundleGraph; + this.bundle = bundle; + this.parcelRequireName = parcelRequireName; + this.useAsyncBundleRuntime = useAsyncBundleRuntime; + this.forceSkipWrapAssets = forceSkipWrapAssets ?? []; + this.logger = logger; + + let OutputFormat = OUTPUT_FORMATS[this.bundle.env.outputFormat]; + this.outputFormat = new OutputFormat(this); + + this.isAsyncBundle = + this.bundleGraph.hasParentBundleOfType(this.bundle, 'js') && + !this.bundle.env.isIsolated() && + this.bundle.bundleBehavior !== 'isolated'; + + this.globalNames = GLOBALS_BY_CONTEXT[bundle.env.context]; + } + + async package(): Promise<{ + contents: string; + map: SourceMap | null | undefined; + }> { + let wrappedAssets = await this.loadAssets(); + this.buildExportedSymbols(); + + // If building a library, the target is actually another bundler rather + // than the final output that could be loaded in a browser. So, loader + // runtimes are excluded, and instead we add imports into the entry bundle + // of each bundle group pointing at the sibling bundles. These can be + // picked up by another bundler later at which point runtimes will be added. + if ( + this.bundle.env.isLibrary || + this.bundle.env.outputFormat === 'commonjs' + ) { + for (let b of this.bundleGraph.getReferencedBundles(this.bundle, { + recursive: false, + })) { + this.externals.set(relativeBundlePath(this.bundle, b), new Map()); + } + } + + let res = ''; + let lineCount = 0; + // @ts-expect-error - TS7034 - Variable 'sourceMap' implicitly has type 'any' in some locations where its type cannot be determined. + let sourceMap = null; + let processAsset = (asset: Asset) => { + let [content, map, lines] = this.visitAsset(asset); + // @ts-expect-error - TS7005 - Variable 'sourceMap' implicitly has an 'any' type. + if (sourceMap && map) { + // @ts-expect-error - TS7005 - Variable 'sourceMap' implicitly has an 'any' type. + sourceMap.addSourceMap(map, lineCount); + } else if (this.bundle.env.sourceMap) { + sourceMap = map; + } + + res += content + '\n'; + lineCount += lines + 1; + }; + + // Hoist wrapped asset to the top of the bundle to ensure that they are registered + // before they are used. + for (let asset of wrappedAssets) { + if (!this.seenAssets.has(asset.id)) { + processAsset(asset); + } + } + + // Add each asset that is directly connected to the bundle. Dependencies will be handled + // by replacing `import` statements in the code. + this.bundle.traverseAssets((asset, _, actions) => { + if (this.seenAssets.has(asset.id)) { + actions.skipChildren(); + return; + } + + processAsset(asset); + actions.skipChildren(); + }); + + let [prelude, preludeLines] = this.buildBundlePrelude(); + res = prelude + res; + lineCount += preludeLines; + // @ts-expect-error - TS2339 - Property 'offsetLines' does not exist on type 'never'. + sourceMap?.offsetLines(1, preludeLines); + + let entries = this.bundle.getEntryAssets(); + let mainEntry = this.bundle.getMainEntry(); + if (this.isAsyncBundle) { + // In async bundles we don't want the main entry to execute until we require it + // as there might be dependencies in a sibling bundle that hasn't loaded yet. + entries = entries.filter((a) => a.id !== mainEntry?.id); + mainEntry = null; + } + + let needsBundleQueue = this.shouldBundleQueue(this.bundle); + + // If any of the entry assets are wrapped, call parcelRequire so they are executed. + for (let entry of entries) { + if (this.wrappedAssets.has(entry.id) && !this.isScriptEntry(entry)) { + let parcelRequire = `parcelRequire(${JSON.stringify( + this.bundleGraph.getAssetPublicId(entry), + )});\n`; + + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + let entryExports = entry.symbols.get('*')?.local; + + if ( + entryExports && + entry === mainEntry && + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + this.exportedSymbols.has(entryExports) + ) { + invariant( + !needsBundleQueue, + 'Entry exports are not yet compaitble with async bundles', + ); + // @ts-expect-error - TS2731 - Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'. + res += `\nvar ${entryExports} = ${parcelRequire}`; + } else { + if (needsBundleQueue) { + parcelRequire = this.runWhenReady(this.bundle, parcelRequire); + } + + res += `\n${parcelRequire}`; + } + + lineCount += 2; + } + } + + let [postlude, postludeLines] = this.outputFormat.buildBundlePostlude(); + res += postlude; + lineCount += postludeLines; + + // The entry asset of a script bundle gets hoisted outside the bundle wrapper so that + // its top-level variables become globals like a real browser script. We need to replace + // all dependency references for runtimes with a parcelRequire call. + if ( + this.bundle.env.outputFormat === 'global' && + this.bundle.env.sourceType === 'script' + ) { + res += '\n'; + lineCount++; + + let mainEntry = nullthrows(this.bundle.getMainEntry()); + let {code, map: mapBuffer} = nullthrows( + this.assetOutputs.get(mainEntry.id), + ); + let map; + if (mapBuffer) { + map = new SourceMap(this.options.projectRoot, mapBuffer); + } + res += replaceScriptDependencies( + this.bundleGraph, + this.bundle, + code, + map, + this.parcelRequireName, + ); + if (sourceMap && map) { + // @ts-expect-error - TS2339 - Property 'addSourceMap' does not exist on type 'never'. + sourceMap.addSourceMap(map, lineCount); + } + } + + return { + contents: res, + map: sourceMap, + }; + } + + shouldBundleQueue(bundle: NamedBundle): boolean { + let referencingBundles = this.bundleGraph.getReferencingBundles(bundle); + let hasHtmlReference = referencingBundles.some((b) => b.type === 'html'); + + return ( + this.useAsyncBundleRuntime && + bundle.type === 'js' && + bundle.bundleBehavior !== 'inline' && + bundle.env.outputFormat === 'esmodule' && + !bundle.env.isIsolated() && + bundle.bundleBehavior !== 'isolated' && + hasHtmlReference + ); + } + + runWhenReady(bundle: NamedBundle, codeToRun: string): string { + let deps = this.bundleGraph + .getReferencedBundles(bundle) + .filter((b) => this.shouldBundleQueue(b)) + .map((b) => b.publicId); + + if (deps.length === 0) { + // If no deps we can safely execute immediately + return codeToRun; + } + + let params = [ + JSON.stringify(this.bundle.publicId), + fnExpr(this.bundle.env, [], [codeToRun]), + JSON.stringify(deps), + ]; + + return `$parcel$global.rwr(${params.join(', ')});`; + } + + async loadAssets(): Promise> { + let queue = new PromiseQueue({maxConcurrent: 32}); + let wrapped: Array = []; + this.bundle.traverseAssets((asset) => { + queue.add(async () => { + let [code, map] = await Promise.all([ + asset.getCode(), + this.bundle.env.sourceMap ? asset.getMapBuffer() : null, + ]); + return [asset.id, {code, map}]; + }); + + if ( + asset.meta.shouldWrap || + this.bundle.env.sourceType === 'script' || + this.bundleGraph.isAssetReferenced(this.bundle, asset) || + this.bundleGraph + .getIncomingDependencies(asset) + .some((dep) => dep.meta.shouldWrap && dep.specifierType !== 'url') + ) { + // Don't wrap constant "entry" modules _except_ if they are referenced by any lazy dependency + if ( + !asset.meta.isConstantModule || + this.bundleGraph + .getIncomingDependencies(asset) + .some((dep) => dep.priority === 'lazy') + ) { + this.wrappedAssets.add(asset.id); + wrapped.push(asset); + } + } + }); + + for (let wrappedAssetRoot of [...wrapped]) { + this.bundle.traverseAssets((asset, _, actions) => { + if (asset === wrappedAssetRoot) { + return; + } + + if (this.wrappedAssets.has(asset.id)) { + actions.skipChildren(); + return; + } + // This prevents children of a wrapped asset also being wrapped - it's an "unsafe" optimisation + // that should only be used when you know (or think you know) what you're doing. + // + // In particular this can force an async bundle to be scope hoisted where it previously would not be + // due to the entry asset being wrapped. + if ( + this.forceSkipWrapAssets.length > 0 && + this.forceSkipWrapAssets.some( + (p) => + p === path.relative(this.options.projectRoot, asset.filePath), + ) + ) { + this.logger.verbose({ + message: `Force skipping wrapping of ${path.relative( + this.options.projectRoot, + asset.filePath, + )}`, + }); + actions.skipChildren(); + return; + } + if (!asset.meta.isConstantModule) { + this.wrappedAssets.add(asset.id); + wrapped.push(asset); + } + }, wrappedAssetRoot); + } + + // @ts-expect-error - TS2769 - No overload matches this call. + this.assetOutputs = new Map(await queue.run()); + return wrapped; + } + + buildExportedSymbols() { + if ( + !this.bundle.env.isLibrary || + this.bundle.env.outputFormat !== 'esmodule' + ) { + return; + } + + // TODO: handle ESM exports of wrapped entry assets... + let entry = this.bundle.getMainEntry(); + if (entry && !this.wrappedAssets.has(entry.id)) { + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + let hasNamespace = entry.symbols.hasExportSymbol('*'); + + for (let { + asset, + exportAs, + symbol, + exportSymbol, + } of this.bundleGraph.getExportedSymbols(entry)) { + if (typeof symbol === 'string') { + // If the module has a namespace (e.g. commonjs), and this is not an entry, only export the namespace + // as default, without individual exports. This mirrors the importing logic in addExternal, avoiding + // extra unused exports and potential for non-identifier export names. + if (hasNamespace && this.isAsyncBundle && exportAs !== '*') { + continue; + } + + let symbols = this.exportedSymbols.get( + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. | TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + symbol === '*' ? nullthrows(entry.symbols.get('*')?.local) : symbol, + )?.exportAs; + + if (!symbols) { + symbols = []; + this.exportedSymbols.set(symbol, { + asset, + // @ts-expect-error - TS2322 - Type 'string | symbol' is not assignable to type 'string'. + exportSymbol, + local: symbol, + exportAs: symbols, + }); + } + + if (exportAs === '*') { + exportAs = 'default'; + } + + // @ts-expect-error - TS2345 - Argument of type 'string | symbol' is not assignable to parameter of type 'string'. + symbols.push(exportAs); + } else if (symbol === null) { + // TODO `meta.exportsIdentifier[exportSymbol]` should be exported + // let relativePath = relative(options.projectRoot, asset.filePath); + // throw getThrowableDiagnosticForNode( + // md`${relativePath} couldn't be statically analyzed when importing '${exportSymbol}'`, + // entry.filePath, + // loc, + // ); + } else if (symbol !== false) { + // let relativePath = relative(options.projectRoot, asset.filePath); + // throw getThrowableDiagnosticForNode( + // md`${relativePath} does not export '${exportSymbol}'`, + // entry.filePath, + // loc, + // ); + } + } + } + } + + getTopLevelName(name: string): string { + name = makeValidIdentifier(name); + if (this.globalNames.has(name)) { + name = '_' + name; + } + + let count = this.topLevelNames.get(name); + if (count == null) { + this.topLevelNames.set(name, 1); + return name; + } + + this.topLevelNames.set(name, count + 1); + return name + count; + } + + getPropertyAccess(obj: string, property: string): string { + if (isValidIdentifier(property)) { + return `${obj}.${property}`; + } + + return `${obj}[${JSON.stringify(property)}]`; + } + + visitAsset(asset: Asset): [string, SourceMap | null | undefined, number] { + invariant(!this.seenAssets.has(asset.id), 'Already visited asset'); + this.seenAssets.add(asset.id); + + let {code, map} = nullthrows(this.assetOutputs.get(asset.id)); + return this.buildAsset(asset, code, map); + } + + buildAsset( + asset: Asset, + code: string, + map?: Buffer | null, + ): [string, SourceMap | null | undefined, number] { + let shouldWrap = this.wrappedAssets.has(asset.id); + let deps = this.bundleGraph.getDependencies(asset); + + let sourceMap = + this.bundle.env.sourceMap && map + ? new SourceMap(this.options.projectRoot, map) + : null; + + // If this asset is skipped, just add dependencies and not the asset's content. + if (this.shouldSkipAsset(asset)) { + let depCode = ''; + let lineCount = 0; + for (let dep of deps) { + let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); + let skipped = this.bundleGraph.isDependencySkipped(dep); + if (skipped) { + continue; + } + + if (!resolved) { + if (!dep.isOptional) { + this.addExternal(dep); + } + + continue; + } + + if ( + this.bundle.hasAsset(resolved) && + !this.seenAssets.has(resolved.id) + ) { + let [code, map, lines] = this.visitAsset(resolved); + depCode += code + '\n'; + if (sourceMap && map) { + // @ts-expect-error - TS2551 - Property 'addSourceMap' does not exist on type 'SourceMap'. Did you mean 'addSources'? + sourceMap.addSourceMap(map, lineCount); + } + lineCount += lines + 1; + } + } + + return [depCode, sourceMap, lineCount]; + } + + // TODO: maybe a meta prop? + if (code.includes('$parcel$global')) { + this.usedHelpers.add('$parcel$global'); + } + + if (this.bundle.env.isNode() && asset.meta.has_node_replacements) { + const relPath = normalizeSeparators( + path.relative(this.bundle.target.distDir, path.dirname(asset.filePath)), + ); + code = code.replace('$parcel$dirnameReplace', relPath); + code = code.replace('$parcel$filenameReplace', relPath); + } + + let [depMap, replacements] = this.buildReplacements(asset, deps); + let [prepend, prependLines, append] = this.buildAssetPrelude( + asset, + deps, + replacements, + ); + if (prependLines > 0) { + sourceMap?.offsetLines(1, prependLines); + code = prepend + code; + } + + code += append; + + let lineCount = 0; + let depContent: Array<[string, NodeSourceMap | null | undefined, number]> = + []; + if (depMap.size === 0 && replacements.size === 0) { + // If there are no dependencies or replacements, use a simple function to count the number of lines. + lineCount = countLines(code) - 1; + } else { + // Otherwise, use a regular expression to perform replacements. + // We need to track how many newlines there are for source maps, replace + // all import statements with dependency code, and perform inline replacements + // of all imported symbols with their resolved export symbols. This is all done + // in a single regex so that we only do one pass over the whole code. + let offset = 0; + let columnStartIndex = 0; + code = code.replace(REPLACEMENT_RE, (m, d, i) => { + if (m === '\n') { + columnStartIndex = i + offset + 1; + lineCount++; + return '\n'; + } + + // If we matched an import, replace with the source code for the dependency. + if (d != null) { + let deps = depMap.get(d); + if (!deps) { + return m; + } + + let replacement = ''; + + // A single `${id}:${specifier}:esm` might have been resolved to multiple assets due to + // reexports. + for (let dep of deps) { + let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); + let skipped = this.bundleGraph.isDependencySkipped(dep); + if (resolved && !skipped) { + // Hoist variable declarations for the referenced parcelRequire dependencies + // after the dependency is declared. This handles the case where the resulting asset + // is wrapped, but the dependency in this asset is not marked as wrapped. This means + // that it was imported/required at the top-level, so its side effects should run immediately. + let [res, lines] = this.getHoistedParcelRequires( + asset, + dep, + resolved, + ); + let map; + if ( + this.bundle.hasAsset(resolved) && + !this.seenAssets.has(resolved.id) + ) { + // If this asset is wrapped, we need to hoist the code for the dependency + // outside our parcelRequire.register wrapper. This is safe because all + // assets referenced by this asset will also be wrapped. Otherwise, inline the + // asset content where the import statement was. + if (shouldWrap) { + depContent.push(this.visitAsset(resolved)); + } else { + let [depCode, depMap, depLines] = this.visitAsset(resolved); + res = depCode + '\n' + res; + lines += 1 + depLines; + map = depMap; + } + } + + // Push this asset's source mappings down by the number of lines in the dependency + // plus the number of hoisted parcelRequires. Then insert the source map for the dependency. + if (sourceMap) { + if (lines > 0) { + sourceMap.offsetLines(lineCount + 1, lines); + } + + if (map) { + // @ts-expect-error - TS2551 - Property 'addSourceMap' does not exist on type 'SourceMap'. Did you mean 'addSources'? + sourceMap.addSourceMap(map, lineCount); + } + } + + replacement += res; + lineCount += lines; + } + } + return replacement; + } + + // If it wasn't a dependency, then it was an inline replacement (e.g. $id$import$foo -> $id$export$foo). + let replacement = replacements.get(m) ?? m; + if (sourceMap) { + // Offset the source map columns for this line if the replacement was a different length. + // This assumes that the match and replacement both do not contain any newlines. + let lengthDifference = replacement.length - m.length; + if (lengthDifference !== 0) { + sourceMap.offsetColumns( + lineCount + 1, + i + offset - columnStartIndex + m.length, + lengthDifference, + ); + offset += lengthDifference; + } + } + return replacement; + }); + } + + // If the asset is wrapped, we need to insert the dependency code outside the parcelRequire.register + // wrapper. Dependencies must be inserted AFTER the asset is registered so that circular dependencies work. + if (shouldWrap) { + // Offset by one line for the parcelRequire.register wrapper. + sourceMap?.offsetLines(1, 1); + lineCount++; + + code = `parcelRegister(${JSON.stringify( + this.bundleGraph.getAssetPublicId(asset), + )}, function(module, exports) { +${code} +}); +`; + + lineCount += 2; + + for (let [depCode, map, lines] of depContent) { + if (!depCode) continue; + code += depCode + '\n'; + if (sourceMap && map) { + // @ts-expect-error - TS2551 - Property 'addSourceMap' does not exist on type 'SourceMap'. Did you mean 'addSources'? + sourceMap.addSourceMap(map, lineCount); + } + lineCount += lines + 1; + } + + this.needsPrelude = true; + } + + if ( + !shouldWrap && + this.shouldBundleQueue(this.bundle) && + this.bundle.getEntryAssets().some((entry) => entry.id === asset.id) + ) { + code = this.runWhenReady(this.bundle, code); + } + + return [code, sourceMap, lineCount]; + } + + buildReplacements( + asset: Asset, + deps: Array, + ): [Map>, Map] { + let assetId = asset.meta.id; + invariant(typeof assetId === 'string'); + + // Build two maps: one of import specifiers, and one of imported symbols to replace. + // These will be used to build a regex below. + let depMap = new DefaultMap>(() => []); + let replacements = new Map(); + for (let dep of deps) { + let specifierType = + dep.specifierType === 'esm' ? `:${dep.specifierType}` : ''; + depMap + .get( + `${assetId}:${getSpecifier(dep)}${ + !dep.meta.placeholder ? specifierType : '' + }`, + ) + .push(dep); + + let asyncResolution = this.bundleGraph.resolveAsyncDependency( + dep, + this.bundle, + ); + let resolved = + asyncResolution?.type === 'asset' + ? // Prefer the underlying asset over a runtime to load it. It will + // be wrapped in Promise.resolve() later. + asyncResolution.value + : this.bundleGraph.getResolvedAsset(dep, this.bundle); + if ( + !resolved && + !dep.isOptional && + !this.bundleGraph.isDependencySkipped(dep) + ) { + this.addExternal(dep, replacements); + } + + if (!resolved) { + continue; + } + + // Handle imports from other bundles in libraries. + if (this.bundle.env.isLibrary && !this.bundle.hasAsset(resolved)) { + let referencedBundle = this.bundleGraph.getReferencedBundle( + dep, + this.bundle, + ); + if ( + referencedBundle && + referencedBundle.getMainEntry() === resolved && + referencedBundle.type === 'js' && + !this.bundleGraph.isAssetReferenced(referencedBundle, resolved) + ) { + this.addExternal(dep, replacements, referencedBundle); + this.externalAssets.add(resolved); + continue; + } + } + + for (let [imported, {local}] of dep.symbols) { + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (local === '*') { + continue; + } + + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + let symbol = this.getSymbolResolution(asset, resolved, imported, dep); + replacements.set( + local, + // If this was an internalized async asset, wrap in a Promise.resolve. + asyncResolution?.type === 'asset' + ? `Promise.resolve(${symbol})` + : symbol, + ); + } + + // Async dependencies need a namespace object even if all used symbols were statically analyzed. + // This is recorded in the promiseSymbol meta property set by the transformer rather than in + // symbols so that we don't mark all symbols as used. + if (dep.priority === 'lazy' && dep.meta.promiseSymbol) { + let promiseSymbol = dep.meta.promiseSymbol; + invariant(typeof promiseSymbol === 'string'); + let symbol = this.getSymbolResolution(asset, resolved, '*', dep); + replacements.set( + promiseSymbol, + asyncResolution?.type === 'asset' + ? `Promise.resolve(${symbol})` + : symbol, + ); + } + } + + // If this asset is wrapped, we need to replace the exports namespace with `module.exports`, + // which will be provided to us by the wrapper. + if ( + this.wrappedAssets.has(asset.id) || + (this.bundle.env.outputFormat === 'commonjs' && + asset === this.bundle.getMainEntry()) + ) { + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + let exportsName = asset.symbols.get('*')?.local || `$${assetId}$exports`; + replacements.set(exportsName, 'module.exports'); + } + + return [depMap, replacements]; + } + + addExternal( + dep: Dependency, + replacements?: Map, + referencedBundle?: NamedBundle, + ) { + if (this.bundle.env.outputFormat === 'global') { + throw new ThrowableDiagnostic({ + diagnostic: { + message: + 'External modules are not supported when building for browser', + codeFrames: [ + { + filePath: nullthrows(dep.sourcePath), + codeHighlights: dep.loc + ? [convertSourceLocationToHighlight(dep.loc)] + : [], + }, + ], + }, + }); + } + + let specifier = dep.specifier; + if (referencedBundle) { + specifier = relativeBundlePath(this.bundle, referencedBundle); + } + + // Map of DependencySpecifier -> Map> + let external = this.externals.get(specifier); + if (!external) { + external = new Map(); + this.externals.set(specifier, external); + } + + for (let [imported, {local}] of dep.symbols) { + // If already imported, just add the already renamed variable to the mapping. + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + let renamed = external.get(imported); + // @ts-expect-error - TS2367 - This condition will always return 'true' since the types 'symbol' and 'string' have no overlap. + if (renamed && local !== '*' && replacements) { + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + replacements.set(local, renamed); + continue; + } + + // For CJS output, always use a property lookup so that exports remain live. + // For ESM output, use named imports which are always live. + if (this.bundle.env.outputFormat === 'commonjs') { + renamed = external.get('*'); + if (!renamed) { + if (referencedBundle) { + let entry = nullthrows(referencedBundle.getMainEntry()); + // @ts-expect-error - TS2322 - Type 'string | symbol' is not assignable to type 'string | undefined'. + renamed = + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + entry.symbols.get('*')?.local ?? + `$${String(entry.meta.id)}$exports`; + } else { + renamed = this.getTopLevelName( + `$${this.bundle.publicId}$${specifier}`, + ); + } + + // @ts-expect-error - TS2345 - Argument of type 'string | undefined' is not assignable to parameter of type 'string'. + external.set('*', renamed); + } + + // @ts-expect-error - TS2367 - This condition will always return 'true' since the types 'symbol' and 'string' have no overlap. + if (local !== '*' && replacements) { + let replacement; + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (imported === '*') { + replacement = renamed; + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + } else if (imported === 'default') { + let needsDefaultInterop = true; + if (referencedBundle) { + let entry = nullthrows(referencedBundle.getMainEntry()); + needsDefaultInterop = this.needsDefaultInterop(entry); + } + if (needsDefaultInterop) { + replacement = `($parcel$interopDefault(${renamed}))`; + this.usedHelpers.add('$parcel$interopDefault'); + } else { + replacement = `${renamed}.default`; + } + } else { + // @ts-expect-error - TS2345 - Argument of type 'string | undefined' is not assignable to parameter of type 'string'. + replacement = this.getPropertyAccess(renamed, imported); + } + + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + replacements.set(local, replacement); + } + } else { + let property; + if (referencedBundle) { + let entry = nullthrows(referencedBundle.getMainEntry()); + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + if (entry.symbols.hasExportSymbol('*')) { + // If importing * and the referenced module has a * export (e.g. CJS), use default instead. + // This mirrors the logic in buildExportedSymbols. + property = imported; + // @ts-expect-error - TS2322 - Type 'string' is not assignable to type 'symbol'. + imported = + referencedBundle?.env.outputFormat === 'esmodule' + ? 'default' + : '*'; + } else { + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (imported === '*') { + let exportedSymbols = this.bundleGraph.getExportedSymbols(entry); + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (local === '*') { + // Re-export all symbols. + for (let exported of exportedSymbols) { + if (exported.symbol) { + // @ts-expect-error - TS2345 - Argument of type 'string | symbol' is not assignable to parameter of type 'string'. + external.set(exported.exportSymbol, exported.symbol); + } + } + continue; + } + } + // @ts-expect-error - TS2322 - Type 'false | symbol | null | undefined' is not assignable to type 'string | undefined'. + renamed = this.bundleGraph.getSymbolResolution( + entry, + imported, + this.bundle, + ).symbol; + } + } + + // Rename the specifier so that multiple local imports of the same imported specifier + // are deduplicated. We have to prefix the imported name with the bundle id so that + // local variables do not shadow it. + if (!renamed) { + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + if (this.exportedSymbols.has(local)) { + // @ts-expect-error - TS2322 - Type 'symbol' is not assignable to type 'string | undefined'. + renamed = local; + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. | TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + } else if (imported === 'default' || imported === '*') { + renamed = this.getTopLevelName( + `$${this.bundle.publicId}$${specifier}`, + ); + } else { + renamed = this.getTopLevelName( + // @ts-expect-error - TS2731 - Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'. + `$${this.bundle.publicId}$${imported}`, + ); + } + } + + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + external.set(imported, renamed); + // @ts-expect-error - TS2367 - This condition will always return 'true' since the types 'symbol' and 'string' have no overlap. + if (local !== '*' && replacements) { + let replacement = renamed; + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol | undefined' and 'string' have no overlap. + if (property === '*') { + replacement = renamed; + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol | undefined' and 'string' have no overlap. + } else if (property === 'default') { + replacement = `($parcel$interopDefault(${renamed}))`; + this.usedHelpers.add('$parcel$interopDefault'); + } else if (property) { + // @ts-expect-error - TS2345 - Argument of type 'string | undefined' is not assignable to parameter of type 'string'. + replacement = this.getPropertyAccess(renamed, property); + } + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + replacements.set(local, replacement); + } + } + } + } + + isWrapped(resolved: Asset, parentAsset: Asset): boolean { + if (resolved.meta.isConstantModule) { + if (!this.bundle.hasAsset(resolved)) { + throw new AssertionError({ + message: `Constant module ${path.relative( + this.options.projectRoot, + resolved.filePath, + )} referenced from ${path.relative( + this.options.projectRoot, + parentAsset.filePath, + )} not found in bundle ${this.bundle.name}`, + }); + } + return false; + } + return ( + (!this.bundle.hasAsset(resolved) && !this.externalAssets.has(resolved)) || + (this.wrappedAssets.has(resolved.id) && resolved !== parentAsset) + ); + } + + getSymbolResolution( + parentAsset: Asset, + resolved: Asset, + imported: string, + dep?: Dependency, + replacements?: Map, + ): string { + let { + asset: resolvedAsset, + exportSymbol, + symbol, + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + } = this.bundleGraph.getSymbolResolution(resolved, imported, this.bundle); + + if ( + resolvedAsset.type !== 'js' || + (dep && this.bundleGraph.isDependencySkipped(dep)) + ) { + // Graceful fallback for non-js imports or when trying to resolve a symbol + // that is actually unused but we still need a placeholder value. + return '{}'; + } + + let isWrapped = this.isWrapped(resolvedAsset, parentAsset); + let staticExports = resolvedAsset.meta.staticExports !== false; + let publicId = this.bundleGraph.getAssetPublicId(resolvedAsset); + + // External CommonJS dependencies need to be accessed as an object property rather than imported + // directly to maintain live binding. + let isExternalCommonJS = + !isWrapped && + this.bundle.env.isLibrary && + this.bundle.env.outputFormat === 'commonjs' && + !this.bundle.hasAsset(resolvedAsset); + + // If the resolved asset is wrapped, but imported at the top-level by this asset, + // then we hoist parcelRequire calls to the top of this asset so side effects run immediately. + if ( + isWrapped && + dep && + !dep?.meta.shouldWrap && + symbol !== false && + // Only do this if the asset is part of a different bundle (so it was definitely + // parcelRequire.register'ed there), or if it is indeed registered in this bundle. + (!this.bundle.hasAsset(resolvedAsset) || + !this.shouldSkipAsset(resolvedAsset)) + ) { + let hoisted = this.hoistedRequires.get(dep.id); + if (!hoisted) { + hoisted = new Map(); + this.hoistedRequires.set(dep.id, hoisted); + } + + hoisted.set( + resolvedAsset.id, + `var $${publicId} = parcelRequire(${JSON.stringify(publicId)});`, + ); + } + + if (isWrapped) { + this.needsPrelude = true; + } + + // If this is an ESM default import of a CJS module with a `default` symbol, + // and no __esModule flag, we need to resolve to the namespace instead. + let isDefaultInterop = + exportSymbol === 'default' && + staticExports && + !isWrapped && + (dep?.meta.kind === 'Import' || dep?.meta.kind === 'Export') && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + resolvedAsset.symbols.hasExportSymbol('*') && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + resolvedAsset.symbols.hasExportSymbol('default') && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + !resolvedAsset.symbols.hasExportSymbol('__esModule'); + + // Find the namespace object for the resolved module. If wrapped and this + // is an inline require (not top-level), use a parcelRequire call, otherwise + // the hoisted variable declared above. Otherwise, if not wrapped, use the + // namespace export symbol. + let assetId = resolvedAsset.meta.id; + invariant(typeof assetId === 'string'); + let obj; + if (isWrapped && (!dep || dep?.meta.shouldWrap)) { + // Wrap in extra parenthesis to not change semantics, e.g.`new (parcelRequire("..."))()`. + obj = `(parcelRequire(${JSON.stringify(publicId)}))`; + } else if (isWrapped && dep) { + obj = `$${publicId}`; + } else { + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + obj = resolvedAsset.symbols.get('*')?.local || `$${assetId}$exports`; + // @ts-expect-error - TS2345 - Argument of type 'string | symbol' is not assignable to parameter of type 'string'. + obj = replacements?.get(obj) || obj; + } + + if (imported === '*' || exportSymbol === '*' || isDefaultInterop) { + // Resolve to the namespace object if requested or this is a CJS default interop reqiure. + if ( + parentAsset === resolvedAsset && + this.wrappedAssets.has(resolvedAsset.id) + ) { + // Directly use module.exports for wrapped assets importing themselves. + return 'module.exports'; + } else { + // @ts-expect-error - TS2322 - Type 'string | symbol' is not assignable to type 'string'. + return obj; + } + } else if ( + (!staticExports || isWrapped || !symbol || isExternalCommonJS) && + resolvedAsset !== parentAsset + ) { + // If the resolved asset is wrapped or has non-static exports, + // we need to use a member access off the namespace object rather + // than a direct reference. If importing default from a CJS module, + // use a helper to check the __esModule flag at runtime. + let kind = dep?.meta.kind; + if ( + (!dep || kind === 'Import' || kind === 'Export') && + exportSymbol === 'default' && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + resolvedAsset.symbols.hasExportSymbol('*') && + this.needsDefaultInterop(resolvedAsset) + ) { + this.usedHelpers.add('$parcel$interopDefault'); + // @ts-expect-error - TS2731 - Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'. + return `(/*@__PURE__*/$parcel$interopDefault(${obj}))`; + } else { + // @ts-expect-error - TS2345 - Argument of type 'string | symbol' is not assignable to parameter of type 'string'. + return this.getPropertyAccess(obj, exportSymbol); + } + } else if (!symbol) { + invariant(false, 'Asset was skipped or not found.'); + } else { + // @ts-expect-error - TS2322 - Type 'string | symbol' is not assignable to type 'string'. | TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + return replacements?.get(symbol) || symbol; + } + } + + getHoistedParcelRequires( + parentAsset: Asset, + dep: Dependency, + resolved: Asset, + ): [string, number] { + if (resolved.type !== 'js') { + return ['', 0]; + } + + let hoisted = this.hoistedRequires.get(dep.id); + let res = ''; + let lineCount = 0; + let isWrapped = this.isWrapped(resolved, parentAsset); + + // If the resolved asset is wrapped and is imported in the top-level by this asset, + // we need to run side effects when this asset runs. If the resolved asset is not + // the first one in the hoisted requires, we need to insert a parcelRequire here + // so it runs first. + if ( + isWrapped && + !dep.meta.shouldWrap && + (!hoisted || hoisted.keys().next().value !== resolved.id) && + !this.bundleGraph.isDependencySkipped(dep) && + !this.shouldSkipAsset(resolved) + ) { + this.needsPrelude = true; + res += `parcelRequire(${JSON.stringify( + this.bundleGraph.getAssetPublicId(resolved), + )});`; + } + + if (hoisted) { + this.needsPrelude = true; + res += '\n' + [...hoisted.values()].join('\n'); + lineCount += hoisted.size; + } + + return [res, lineCount]; + } + + buildAssetPrelude( + asset: Asset, + deps: Array, + replacements: Map, + ): [string, number, string] { + let prepend = ''; + let prependLineCount = 0; + let append = ''; + + let shouldWrap = this.wrappedAssets.has(asset.id); + let usedSymbols = nullthrows(this.bundleGraph.getUsedSymbols(asset)); + let assetId = asset.meta.id; + invariant(typeof assetId === 'string'); + + // If the asset has a namespace export symbol, it is CommonJS. + // If there's no __esModule flag, and default is a used symbol, we need + // to insert an interop helper. + let defaultInterop = + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + asset.symbols.hasExportSymbol('*') && + usedSymbols.has('default') && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + !asset.symbols.hasExportSymbol('__esModule'); + + let usedNamespace = + // If the asset has * in its used symbols, we might need the exports namespace. + // The one case where this isn't true is in ESM library entries, where the only + // dependency on * is the entry dependency. In this case, we will use ESM exports + // instead of the namespace object. + (usedSymbols.has('*') && + (this.bundle.env.outputFormat !== 'esmodule' || + !this.bundle.env.isLibrary || + asset !== this.bundle.getMainEntry() || + this.bundleGraph + .getIncomingDependencies(asset) + .some( + (dep) => + !dep.isEntry && + this.bundle.hasDependency(dep) && + nullthrows(this.bundleGraph.getUsedSymbols(dep)).has('*'), + ))) || + // If a symbol is imported (used) from a CJS asset but isn't listed in the symbols, + // we fallback on the namespace object. + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + (asset.symbols.hasExportSymbol('*') && + [...usedSymbols].some((s) => !asset.symbols.hasExportSymbol(s))) || + // If the exports has this asset's namespace (e.g. ESM output from CJS input), + // include the namespace object for the default export. + this.exportedSymbols.has(`$${assetId}$exports`) || + // CommonJS library bundle entries always need a namespace. + (this.bundle.env.isLibrary && + this.bundle.env.outputFormat === 'commonjs' && + asset === this.bundle.getMainEntry()); + + // If the asset doesn't have static exports, should wrap, the namespace is used, + // or we need default interop, then we need to synthesize a namespace object for + // this asset. + if ( + asset.meta.staticExports === false || + shouldWrap || + usedNamespace || + defaultInterop + ) { + // Insert a declaration for the exports namespace object. If the asset is wrapped + // we don't need to do this, because we'll use the `module.exports` object provided + // by the wrapper instead. This is also true of CommonJS entry assets, which will use + // the `module.exports` object provided by CJS. + if ( + !shouldWrap && + (this.bundle.env.outputFormat !== 'commonjs' || + asset !== this.bundle.getMainEntry()) + ) { + prepend += `var $${assetId}$exports = {};\n`; + prependLineCount++; + } + + // Insert the __esModule interop flag for this module if it has a `default` export + // and the namespace symbol is used. + // TODO: only if required by CJS? + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + if (asset.symbols.hasExportSymbol('default') && usedSymbols.has('*')) { + prepend += `\n$parcel$defineInteropFlag($${assetId}$exports);\n`; + prependLineCount += 2; + this.usedHelpers.add('$parcel$defineInteropFlag'); + } + + // Find wildcard re-export dependencies, and make sure their exports are also included in + // ours. Importantly, add them before the asset's own exports so that wildcard exports get + // correctly overwritten by own exports of the same name. + for (let dep of deps) { + let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); + if (dep.isOptional || this.bundleGraph.isDependencySkipped(dep)) { + continue; + } + + let isWrapped = resolved && resolved.meta.shouldWrap; + + for (let [imported, {local}] of dep.symbols) { + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. | TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (imported === '*' && local === '*') { + if (!resolved) { + // Re-exporting an external module. This should have already been handled in buildReplacements. + let external = nullthrows( + nullthrows(this.externals.get(dep.specifier)).get('*'), + ); + append += `$parcel$exportWildcard($${assetId}$exports, ${external});\n`; + this.usedHelpers.add('$parcel$exportWildcard'); + continue; + } + + // If the resolved asset has an exports object, use the $parcel$exportWildcard helper + // to re-export all symbols. Otherwise, if there's no namespace object available, add + // $parcel$export calls for each used symbol of the dependency. + if ( + isWrapped || + resolved.meta.staticExports === false || + nullthrows(this.bundleGraph.getUsedSymbols(resolved)).has('*') || + // an empty asset + (!resolved.meta.hasCJSExports && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + resolved.symbols.hasExportSymbol('*')) + ) { + let obj = this.getSymbolResolution( + asset, + resolved, + '*', + dep, + replacements, + ); + append += `$parcel$exportWildcard($${assetId}$exports, ${obj});\n`; + this.usedHelpers.add('$parcel$exportWildcard'); + } else { + for (let symbol of nullthrows( + this.bundleGraph.getUsedSymbols(dep), + )) { + if ( + symbol === 'default' || // `export * as ...` does not include the default export + symbol === '__esModule' + ) { + continue; + } + + let resolvedSymbol = this.getSymbolResolution( + asset, + resolved, + symbol, + undefined, + replacements, + ); + let get = this.buildFunctionExpression([], resolvedSymbol); + let set = asset.meta.hasCJSExports + ? ', ' + + this.buildFunctionExpression(['v'], `${resolvedSymbol} = v`) + : ''; + prepend += `$parcel$export($${assetId}$exports, ${JSON.stringify( + symbol, + )}, ${get}${set});\n`; + this.usedHelpers.add('$parcel$export'); + prependLineCount++; + } + } + } + } + } + + // Find the used exports of this module. This is based on the used symbols of + // incoming dependencies rather than the asset's own used exports so that we include + // re-exported symbols rather than only symbols declared in this asset. + let incomingDeps = this.bundleGraph.getIncomingDependencies(asset); + let usedExports = [...asset.symbols.exportSymbols()].filter((symbol) => { + // @ts-expect-error - TS2367 - This condition will always return 'false' since the types 'symbol' and 'string' have no overlap. + if (symbol === '*') { + return false; + } + + // If we need default interop, then all symbols are needed because the `default` + // symbol really maps to the whole namespace. + if (defaultInterop) { + return true; + } + + let unused = incomingDeps.every((d) => { + let symbols = nullthrows(this.bundleGraph.getUsedSymbols(d)); + return !symbols.has(symbol) && !symbols.has('*'); + }); + return !unused; + }); + + if (usedExports.length > 0) { + // Insert $parcel$export calls for each of the used exports. This creates a getter/setter + // for the symbol so that when the value changes the object property also changes. This is + // required to simulate ESM live bindings. It's easier to do it this way rather than inserting + // additional assignments after each mutation of the original binding. + prepend += `\n${usedExports + .map((exp) => { + let resolved = this.getSymbolResolution( + asset, + asset, + // @ts-expect-error - TS2345 - Argument of type 'symbol' is not assignable to parameter of type 'string'. + exp, + undefined, + replacements, + ); + let get = this.buildFunctionExpression([], resolved); + let isEsmExport = !!asset.symbols.get(exp)?.meta?.isEsm; + let set = + !isEsmExport && asset.meta.hasCJSExports + ? ', ' + this.buildFunctionExpression(['v'], `${resolved} = v`) + : ''; + return `$parcel$export($${assetId}$exports, ${JSON.stringify( + exp, + )}, ${get}${set});`; + }) + .join('\n')}\n`; + this.usedHelpers.add('$parcel$export'); + prependLineCount += 1 + usedExports.length; + } + } + + return [prepend, prependLineCount, append]; + } + + buildBundlePrelude(): [string, number] { + let enableSourceMaps = this.bundle.env.sourceMap; + let res = ''; + let lines = 0; + + // Add hashbang if the entry asset recorded an interpreter. + let mainEntry = this.bundle.getMainEntry(); + if ( + mainEntry && + !this.isAsyncBundle && + !this.bundle.target.env.isBrowser() + ) { + let interpreter = mainEntry.meta.interpreter; + invariant(interpreter == null || typeof interpreter === 'string'); + if (interpreter != null) { + res += `#!${interpreter}\n`; + lines++; + } + } + + // The output format may have specific things to add at the start of the bundle (e.g. imports). + let [outputFormatPrelude, outputFormatLines] = + this.outputFormat.buildBundlePrelude(); + res += outputFormatPrelude; + lines += outputFormatLines; + + // Add used helpers. + if (this.needsPrelude) { + this.usedHelpers.add('$parcel$global'); + } + + for (let helper of this.usedHelpers) { + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly $parcel$export: "\nfunction $parcel$export(e, n, v, s) {\n Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true});\n}\n"; readonly $parcel$exportWildcard: "\nfunction $parcel$exportWildcard(dest, source) {\n Object.keys(source).forEach(function(key) {\n if (key === 'defau...'. + let currentHelper = helpers[helper]; + if (typeof currentHelper === 'function') { + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly $parcel$export: "\nfunction $parcel$export(e, n, v, s) {\n Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true});\n}\n"; readonly $parcel$exportWildcard: "\nfunction $parcel$exportWildcard(dest, source) {\n Object.keys(source).forEach(function(key) {\n if (key === 'defau...'. + currentHelper = helpers[helper](this.bundle.env); + } + res += currentHelper; + if (enableSourceMaps) { + lines += countLines(currentHelper) - 1; + } + } + + if (this.needsPrelude) { + // Add the prelude if this is potentially the first JS bundle to load in a + // particular context (e.g. entry scripts in HTML, workers, etc.). + let parentBundles = this.bundleGraph.getParentBundles(this.bundle); + let mightBeFirstJS = + parentBundles.length === 0 || + parentBundles.some((b) => b.type !== 'js') || + this.bundleGraph + .getBundleGroupsContainingBundle(this.bundle) + .some((g) => this.bundleGraph.isEntryBundleGroup(g)) || + this.bundle.env.isIsolated() || + this.bundle.bundleBehavior === 'isolated'; + + if (mightBeFirstJS) { + let preludeCode = prelude(this.parcelRequireName); + res += preludeCode; + if (enableSourceMaps) { + lines += countLines(preludeCode) - 1; + } + + if (this.shouldBundleQueue(this.bundle)) { + let bundleQueuePreludeCode = bundleQueuePrelude(this.bundle.env); + res += bundleQueuePreludeCode; + if (enableSourceMaps) { + lines += countLines(bundleQueuePreludeCode) - 1; + } + } + } else { + // Otherwise, get the current parcelRequire global. + const escaped = JSON.stringify(this.parcelRequireName); + res += `var parcelRequire = $parcel$global[${escaped}];\n`; + lines++; + res += `var parcelRegister = parcelRequire.register;\n`; + lines++; + } + } + + // Add importScripts for sibling bundles in workers. + if (this.bundle.env.isWorker() || this.bundle.env.isWorklet()) { + let importScripts = ''; + let bundles = this.bundleGraph.getReferencedBundles(this.bundle); + for (let b of bundles) { + if (this.bundle.env.outputFormat === 'esmodule') { + // importScripts() is not allowed in native ES module workers. + importScripts += `import "${relativeBundlePath(this.bundle, b)}";\n`; + } else { + importScripts += `importScripts("${relativeBundlePath( + this.bundle, + b, + )}");\n`; + } + } + + res += importScripts; + lines += bundles.length; + } + + return [res, lines]; + } + + needsDefaultInterop(asset: Asset): boolean { + if ( + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + asset.symbols.hasExportSymbol('*') && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + !asset.symbols.hasExportSymbol('default') + ) { + if (getFeatureFlag('fastNeedsDefaultInterop')) { + return true; + } + + let deps = this.bundleGraph.getIncomingDependencies(asset); + return deps.some( + (dep) => + this.bundle.hasDependency(dep) && + // dep.meta.isES6Module && + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + dep.symbols.hasExportSymbol('default'), + ); + } + + return false; + } + + shouldSkipAsset(asset: Asset): boolean { + if (this.isScriptEntry(asset)) { + return true; + } + + return ( + asset.sideEffects === false && + nullthrows(this.bundleGraph.getUsedSymbols(asset)).size == 0 && + !this.bundleGraph.isAssetReferenced(this.bundle, asset) + ); + } + + isScriptEntry(asset: Asset): boolean { + return ( + this.bundle.env.outputFormat === 'global' && + this.bundle.env.sourceType === 'script' && + asset === this.bundle.getMainEntry() + ); + } + + buildFunctionExpression(args: Array, expr: string): string { + return this.bundle.env.supports('arrow-functions', true) + ? `(${args.join(', ')}) => ${expr}` + : `function (${args.join(', ')}) { return ${expr}; }`; + } +} diff --git a/packages/packagers/js/src/helpers.js b/packages/packagers/js/src/helpers.js deleted file mode 100644 index 1ece9a297..000000000 --- a/packages/packagers/js/src/helpers.js +++ /dev/null @@ -1,169 +0,0 @@ -// @flow strict-local -import type {Environment} from '@atlaspack/types'; - -export const prelude = (parcelRequireName: string): string => ` -var $parcel$modules = {}; -var $parcel$inits = {}; - -var parcelRequire = $parcel$global[${JSON.stringify(parcelRequireName)}]; - -if (parcelRequire == null) { - parcelRequire = function(id) { - if (id in $parcel$modules) { - return $parcel$modules[id].exports; - } - if (id in $parcel$inits) { - var init = $parcel$inits[id]; - delete $parcel$inits[id]; - var module = {id: id, exports: {}}; - $parcel$modules[id] = module; - init.call(module.exports, module, module.exports); - return module.exports; - } - var err = new Error("Cannot find module '" + id + "'"); - err.code = 'MODULE_NOT_FOUND'; - throw err; - }; - - parcelRequire.register = function register(id, init) { - $parcel$inits[id] = init; - }; - - $parcel$global[${JSON.stringify(parcelRequireName)}] = parcelRequire; -} - -var parcelRegister = parcelRequire.register; -`; - -export const fnExpr = ( - env: Environment, - params: Array, - body: Array, -): string => { - let block = `{ ${body.join(' ')} }`; - - if (env.supports('arrow-functions')) { - return `(${params.join(', ')}) => ${block}`; - } - - return `function (${params.join(', ')}) ${block}`; -}; - -export const bundleQueuePrelude = (env: Environment): string => ` -if (!$parcel$global.lb) { - // Set of loaded bundles - $parcel$global.lb = new Set(); - // Queue of bundles to execute once they're dep bundles are loaded - $parcel$global.bq = []; - - // Register loaded bundle - $parcel$global.rlb = ${fnExpr( - env, - ['bundle'], - ['$parcel$global.lb.add(bundle);', '$parcel$global.pq();'], - )} - - // Run when ready - $parcel$global.rwr = ${fnExpr( - env, - // b = bundle public id - // r = run function to execute the bundle entry - // d = list of dependent bundles this bundle requires before executing - ['b', 'r', 'd'], - ['$parcel$global.bq.push({b, r, d});', '$parcel$global.pq();'], - )} - - // Process queue - $parcel$global.pq = ${fnExpr( - env, - [], - [ - `var runnableEntry = $parcel$global.bq.find(${fnExpr( - env, - ['i'], - [ - `return i.d.every(${fnExpr( - env, - ['dep'], - ['return $parcel$global.lb.has(dep);'], - )});`, - ], - )});`, - 'if (runnableEntry) {', - `$parcel$global.bq = $parcel$global.bq.filter(${fnExpr( - env, - ['i'], - ['return i.b !== runnableEntry.b;'], - )});`, - 'runnableEntry.r();', - '$parcel$global.pq();', - '}', - ], - )} -} -`; - -const $parcel$export = ` -function $parcel$export(e, n, v, s) { - Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true}); -} -`; - -const $parcel$exportWildcard = ` -function $parcel$exportWildcard(dest, source) { - Object.keys(source).forEach(function(key) { - if (key === 'default' || key === '__esModule' || Object.prototype.hasOwnProperty.call(dest, key)) { - return; - } - - Object.defineProperty(dest, key, { - enumerable: true, - get: function get() { - return source[key]; - } - }); - }); - - return dest; -} -`; - -const $parcel$interopDefault = ` -function $parcel$interopDefault(a) { - return a && a.__esModule ? a.default : a; -} -`; - -const $parcel$global = (env: Environment): string => { - if (env.supports('global-this')) { - return ` - var $parcel$global = globalThis; - `; - } - return ` - var $parcel$global = - typeof globalThis !== 'undefined' - ? globalThis - : typeof self !== 'undefined' - ? self - : typeof window !== 'undefined' - ? window - : typeof global !== 'undefined' - ? global - : {}; - `; -}; - -const $parcel$defineInteropFlag = ` -function $parcel$defineInteropFlag(a) { - Object.defineProperty(a, '__esModule', {value: true, configurable: true}); -} -`; - -export const helpers = { - $parcel$export, - $parcel$exportWildcard, - $parcel$interopDefault, - $parcel$global, - $parcel$defineInteropFlag, -}; diff --git a/packages/packagers/js/src/helpers.ts b/packages/packagers/js/src/helpers.ts new file mode 100644 index 000000000..b3d1553e9 --- /dev/null +++ b/packages/packagers/js/src/helpers.ts @@ -0,0 +1,168 @@ +import type {Environment} from '@atlaspack/types'; + +export const prelude = (parcelRequireName: string): string => ` +var $parcel$modules = {}; +var $parcel$inits = {}; + +var parcelRequire = $parcel$global[${JSON.stringify(parcelRequireName)}]; + +if (parcelRequire == null) { + parcelRequire = function(id) { + if (id in $parcel$modules) { + return $parcel$modules[id].exports; + } + if (id in $parcel$inits) { + var init = $parcel$inits[id]; + delete $parcel$inits[id]; + var module = {id: id, exports: {}}; + $parcel$modules[id] = module; + init.call(module.exports, module, module.exports); + return module.exports; + } + var err = new Error("Cannot find module '" + id + "'"); + err.code = 'MODULE_NOT_FOUND'; + throw err; + }; + + parcelRequire.register = function register(id, init) { + $parcel$inits[id] = init; + }; + + $parcel$global[${JSON.stringify(parcelRequireName)}] = parcelRequire; +} + +var parcelRegister = parcelRequire.register; +`; + +export const fnExpr = ( + env: Environment, + params: Array, + body: Array, +): string => { + let block = `{ ${body.join(' ')} }`; + + if (env.supports('arrow-functions')) { + return `(${params.join(', ')}) => ${block}`; + } + + return `function (${params.join(', ')}) ${block}`; +}; + +export const bundleQueuePrelude = (env: Environment): string => ` +if (!$parcel$global.lb) { + // Set of loaded bundles + $parcel$global.lb = new Set(); + // Queue of bundles to execute once they're dep bundles are loaded + $parcel$global.bq = []; + + // Register loaded bundle + $parcel$global.rlb = ${fnExpr( + env, + ['bundle'], + ['$parcel$global.lb.add(bundle);', '$parcel$global.pq();'], + )} + + // Run when ready + $parcel$global.rwr = ${fnExpr( + env, + // b = bundle public id + // r = run function to execute the bundle entry + // d = list of dependent bundles this bundle requires before executing + ['b', 'r', 'd'], + ['$parcel$global.bq.push({b, r, d});', '$parcel$global.pq();'], + )} + + // Process queue + $parcel$global.pq = ${fnExpr( + env, + [], + [ + `var runnableEntry = $parcel$global.bq.find(${fnExpr( + env, + ['i'], + [ + `return i.d.every(${fnExpr( + env, + ['dep'], + ['return $parcel$global.lb.has(dep);'], + )});`, + ], + )});`, + 'if (runnableEntry) {', + `$parcel$global.bq = $parcel$global.bq.filter(${fnExpr( + env, + ['i'], + ['return i.b !== runnableEntry.b;'], + )});`, + 'runnableEntry.r();', + '$parcel$global.pq();', + '}', + ], + )} +} +`; + +const $parcel$export = ` +function $parcel$export(e, n, v, s) { + Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true}); +} +`; + +const $parcel$exportWildcard = ` +function $parcel$exportWildcard(dest, source) { + Object.keys(source).forEach(function(key) { + if (key === 'default' || key === '__esModule' || Object.prototype.hasOwnProperty.call(dest, key)) { + return; + } + + Object.defineProperty(dest, key, { + enumerable: true, + get: function get() { + return source[key]; + } + }); + }); + + return dest; +} +`; + +const $parcel$interopDefault = ` +function $parcel$interopDefault(a) { + return a && a.__esModule ? a.default : a; +} +`; + +const $parcel$global = (env: Environment): string => { + if (env.supports('global-this')) { + return ` + var $parcel$global = globalThis; + `; + } + return ` + var $parcel$global = + typeof globalThis !== 'undefined' + ? globalThis + : typeof self !== 'undefined' + ? self + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}; + `; +}; + +const $parcel$defineInteropFlag = ` +function $parcel$defineInteropFlag(a) { + Object.defineProperty(a, '__esModule', {value: true, configurable: true}); +} +`; + +export const helpers = { + $parcel$export, + $parcel$exportWildcard, + $parcel$interopDefault, + $parcel$global, + $parcel$defineInteropFlag, +} as const; diff --git a/packages/packagers/js/src/index.js b/packages/packagers/js/src/index.js deleted file mode 100644 index 08304cc38..000000000 --- a/packages/packagers/js/src/index.js +++ /dev/null @@ -1,162 +0,0 @@ -// @flow strict-local -import type {Async} from '@atlaspack/types'; -import type SourceMap from '@parcel/source-map'; -import {Packager} from '@atlaspack/plugin'; -import { - replaceInlineReferences, - replaceURLReferences, - validateSchema, - type SchemaEntity, -} from '@atlaspack/utils'; -import {encodeJSONKeyComponent} from '@atlaspack/diagnostic'; -import {hashString} from '@atlaspack/rust'; -import nullthrows from 'nullthrows'; -import {DevPackager} from './DevPackager'; -import {ScopeHoistingPackager} from './ScopeHoistingPackager'; - -type JSPackagerConfig = {| - parcelRequireName: string, - unstable_asyncBundleRuntime: boolean, - unstable_forceSkipWrapAssets: Array, -|}; - -const CONFIG_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - unstable_asyncBundleRuntime: { - type: 'boolean', - }, - unstable_forceSkipWrapAssets: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - additionalProperties: false, -}; - -export default (new Packager({ - async loadConfig({config, options}): Promise { - let packageKey = '@atlaspack/packager-js'; - let conf = await config.getConfigFrom(options.projectRoot + '/index', [], { - packageKey, - }); - - if (conf?.contents) { - validateSchema.diagnostic( - CONFIG_SCHEMA, - { - data: conf?.contents, - source: await options.inputFS.readFile(conf.filePath, 'utf8'), - filePath: conf.filePath, - prependKey: `/${encodeJSONKeyComponent(packageKey)}`, - }, - packageKey, - `Invalid config for ${packageKey}`, - ); - } - - // Generate a name for the global parcelRequire function that is unique to this project. - // This allows multiple parcel builds to coexist on the same page. - let packageName = await config.getConfigFrom( - options.projectRoot + '/index', - [], - { - packageKey: 'name', - }, - ); - - let name = packageName?.contents?.name ?? ''; - return { - parcelRequireName: 'parcelRequire' + hashString(name).slice(-4), - unstable_asyncBundleRuntime: Boolean( - conf?.contents?.unstable_asyncBundleRuntime, - ), - unstable_forceSkipWrapAssets: - conf?.contents?.unstable_forceSkipWrapAssets ?? [], - }; - }, - async package({ - bundle, - bundleGraph, - getInlineBundleContents, - getSourceMapReference, - config, - options, - logger, - }) { - // If this is a non-module script, and there is only one asset with no dependencies, - // then we don't need to package at all and can pass through the original code un-wrapped. - let contents, map; - if (bundle.env.sourceType === 'script') { - let entries = bundle.getEntryAssets(); - if ( - entries.length === 1 && - bundleGraph.getDependencies(entries[0]).length === 0 - ) { - contents = await entries[0].getCode(); - map = await entries[0].getMap(); - } - } - - if (contents == null) { - let packager = bundle.env.shouldScopeHoist - ? new ScopeHoistingPackager( - options, - bundleGraph, - bundle, - nullthrows(config).parcelRequireName, - nullthrows(config).unstable_asyncBundleRuntime, - nullthrows(config).unstable_forceSkipWrapAssets, - logger, - ) - : new DevPackager( - options, - bundleGraph, - bundle, - nullthrows(config).parcelRequireName, - ); - - ({contents, map} = await packager.package()); - } - - contents += '\n' + (await getSourceMapSuffix(getSourceMapReference, map)); - - // For library builds, we need to replace URL references with their final resolved paths. - // For non-library builds, this is handled in the JS runtime. - if (bundle.env.isLibrary) { - ({contents, map} = replaceURLReferences({ - bundle, - bundleGraph, - contents, - map, - getReplacement: s => JSON.stringify(s).slice(1, -1), - })); - } - - return replaceInlineReferences({ - bundle, - bundleGraph, - contents, - getInlineReplacement: (dependency, inlineType, content) => ({ - from: `"${dependency.id}"`, - to: inlineType === 'string' ? JSON.stringify(content) : content, - }), - getInlineBundleContents, - map, - }); - }, -}): Packager); - -async function getSourceMapSuffix( - getSourceMapReference: (?SourceMap) => Async, - map: ?SourceMap, -): Promise { - let sourcemapReference = await getSourceMapReference(map); - if (sourcemapReference != null) { - return '//# sourceMappingURL=' + sourcemapReference + '\n'; - } else { - return ''; - } -} diff --git a/packages/packagers/js/src/index.ts b/packages/packagers/js/src/index.ts new file mode 100644 index 000000000..99d22bc41 --- /dev/null +++ b/packages/packagers/js/src/index.ts @@ -0,0 +1,170 @@ +import type {Async} from '@atlaspack/types'; +import type SourceMap from '@parcel/source-map'; +import {Packager} from '@atlaspack/plugin'; +import { + replaceInlineReferences, + replaceURLReferences, + validateSchema, + SchemaEntity, +} from '@atlaspack/utils'; +import {encodeJSONKeyComponent} from '@atlaspack/diagnostic'; +import {hashString} from '@atlaspack/rust'; +import nullthrows from 'nullthrows'; +import {DevPackager} from './DevPackager'; +import {ScopeHoistingPackager} from './ScopeHoistingPackager'; + +type JSPackagerConfig = { + parcelRequireName: string; + unstable_asyncBundleRuntime: boolean; + unstable_forceSkipWrapAssets: Array; +}; + +const CONFIG_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + unstable_asyncBundleRuntime: { + type: 'boolean', + }, + unstable_forceSkipWrapAssets: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, +}; + +export default new Packager({ + async loadConfig({config, options}): Promise { + let packageKey = '@atlaspack/packager-js'; + let conf = await config.getConfigFrom(options.projectRoot + '/index', [], { + packageKey, + }); + + if (conf?.contents) { + validateSchema.diagnostic( + CONFIG_SCHEMA, + { + data: conf?.contents, + source: await options.inputFS.readFile(conf.filePath, 'utf8'), + filePath: conf.filePath, + prependKey: `/${encodeJSONKeyComponent(packageKey)}`, + }, + packageKey, + `Invalid config for ${packageKey}`, + ); + } + + // Generate a name for the global parcelRequire function that is unique to this project. + // This allows multiple parcel builds to coexist on the same page. + let packageName = await config.getConfigFrom( + options.projectRoot + '/index', + [], + { + packageKey: 'name', + }, + ); + + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + let name = packageName?.contents?.name ?? ''; + return { + parcelRequireName: 'parcelRequire' + hashString(name).slice(-4), + unstable_asyncBundleRuntime: Boolean( + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + conf?.contents?.unstable_asyncBundleRuntime, + ), + unstable_forceSkipWrapAssets: + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + conf?.contents?.unstable_forceSkipWrapAssets ?? [], + }; + }, + async package({ + bundle, + bundleGraph, + getInlineBundleContents, + getSourceMapReference, + config, + options, + logger, + }) { + // If this is a non-module script, and there is only one asset with no dependencies, + // then we don't need to package at all and can pass through the original code un-wrapped. + let contents, map; + if (bundle.env.sourceType === 'script') { + let entries = bundle.getEntryAssets(); + if ( + entries.length === 1 && + bundleGraph.getDependencies(entries[0]).length === 0 + ) { + contents = await entries[0].getCode(); + map = await entries[0].getMap(); + } + } + + if (contents == null) { + let packager = bundle.env.shouldScopeHoist + ? new ScopeHoistingPackager( + options, + bundleGraph, + bundle, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + nullthrows(config).parcelRequireName, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + nullthrows(config).unstable_asyncBundleRuntime, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + nullthrows(config).unstable_forceSkipWrapAssets, + logger, + ) + : new DevPackager( + options, + bundleGraph, + bundle, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + nullthrows(config).parcelRequireName, + ); + + ({contents, map} = await packager.package()); + } + + contents += '\n' + (await getSourceMapSuffix(getSourceMapReference, map)); + + // For library builds, we need to replace URL references with their final resolved paths. + // For non-library builds, this is handled in the JS runtime. + if (bundle.env.isLibrary) { + ({contents, map} = replaceURLReferences({ + bundle, + bundleGraph, + contents, + map, + getReplacement: (s) => JSON.stringify(s).slice(1, -1), + })); + } + + return replaceInlineReferences({ + bundle, + bundleGraph, + contents, + getInlineReplacement: (dependency, inlineType, content) => ({ + from: `"${dependency.id}"`, + to: inlineType === 'string' ? JSON.stringify(content) : content, + }), + getInlineBundleContents, + map, + }); + }, +}) as Packager; + +async function getSourceMapSuffix( + getSourceMapReference: ( + arg1?: SourceMap | null | undefined, + ) => Async, + map?: SourceMap | null, +): Promise { + let sourcemapReference = await getSourceMapReference(map); + if (sourcemapReference != null) { + return '//# sourceMappingURL=' + sourcemapReference + '\n'; + } else { + return ''; + } +} diff --git a/packages/packagers/js/src/utils.js b/packages/packagers/js/src/utils.js deleted file mode 100644 index c731bb1cf..000000000 --- a/packages/packagers/js/src/utils.js +++ /dev/null @@ -1,74 +0,0 @@ -// @flow -import type {BundleGraph, Dependency, NamedBundle} from '@atlaspack/types'; -import type SourceMap from '@parcel/source-map'; -import nullthrows from 'nullthrows'; - -// This replaces __parcel__require__ references left by the transformer with -// parcelRequire calls of the resolved asset id. This lets runtimes work within -// script bundles, which must be outside the bundle wrapper so their variables are global. -export function replaceScriptDependencies( - bundleGraph: BundleGraph, - bundle: NamedBundle, - code: string, - map: ?SourceMap, - parcelRequireName: string, -): string { - let entry = nullthrows(bundle.getMainEntry()); - let dependencies = bundleGraph.getDependencies(entry); - - let lineCount = 0; - let offset = 0; - let columnStartIndex = 0; - code = code.replace(/\n|__parcel__require__\(['"](.*?)['"]\)/g, (m, s, i) => { - if (m === '\n') { - columnStartIndex = i + offset + 1; - lineCount++; - return '\n'; - } - - let dep = nullthrows(dependencies.find(d => getSpecifier(d) === s)); - let resolved = nullthrows(bundleGraph.getResolvedAsset(dep, bundle)); - let publicId = bundleGraph.getAssetPublicId(resolved); - let replacement = `${parcelRequireName}("${publicId}")`; - if (map) { - let lengthDifference = replacement.length - m.length; - if (lengthDifference !== 0) { - map.offsetColumns( - lineCount + 1, - i + offset - columnStartIndex + m.length, - lengthDifference, - ); - offset += lengthDifference; - } - } - - return replacement; - }); - - return code; -} - -export function getSpecifier(dep: Dependency): string { - if (typeof dep.meta.placeholder === 'string') { - return dep.meta.placeholder; - } - - return dep.specifier; -} - -// https://262.ecma-international.org/6.0/#sec-names-and-keywords -const IDENTIFIER_RE = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u; -const ID_START_RE = /^[$_\p{ID_Start}]/u; -const NON_ID_CONTINUE_RE = /[^$_\u200C\u200D\p{ID_Continue}]/gu; - -export function isValidIdentifier(id: string): boolean { - return IDENTIFIER_RE.test(id); -} - -export function makeValidIdentifier(name: string): string { - name = name.replace(NON_ID_CONTINUE_RE, ''); - if (!ID_START_RE.test(name)) { - name = '_' + name; - } - return name; -} diff --git a/packages/packagers/js/src/utils.ts b/packages/packagers/js/src/utils.ts new file mode 100644 index 000000000..c7b432d49 --- /dev/null +++ b/packages/packagers/js/src/utils.ts @@ -0,0 +1,73 @@ +import type {BundleGraph, Dependency, NamedBundle} from '@atlaspack/types'; +import type SourceMap from '@parcel/source-map'; +import nullthrows from 'nullthrows'; + +// This replaces __parcel__require__ references left by the transformer with +// parcelRequire calls of the resolved asset id. This lets runtimes work within +// script bundles, which must be outside the bundle wrapper so their variables are global. +export function replaceScriptDependencies( + bundleGraph: BundleGraph, + bundle: NamedBundle, + code: string, + map: SourceMap | null | undefined, + parcelRequireName: string, +): string { + let entry = nullthrows(bundle.getMainEntry()); + let dependencies = bundleGraph.getDependencies(entry); + + let lineCount = 0; + let offset = 0; + let columnStartIndex = 0; + code = code.replace(/\n|__parcel__require__\(['"](.*?)['"]\)/g, (m, s, i) => { + if (m === '\n') { + columnStartIndex = i + offset + 1; + lineCount++; + return '\n'; + } + + let dep = nullthrows(dependencies.find((d) => getSpecifier(d) === s)); + let resolved = nullthrows(bundleGraph.getResolvedAsset(dep, bundle)); + let publicId = bundleGraph.getAssetPublicId(resolved); + let replacement = `${parcelRequireName}("${publicId}")`; + if (map) { + let lengthDifference = replacement.length - m.length; + if (lengthDifference !== 0) { + map.offsetColumns( + lineCount + 1, + i + offset - columnStartIndex + m.length, + lengthDifference, + ); + offset += lengthDifference; + } + } + + return replacement; + }); + + return code; +} + +export function getSpecifier(dep: Dependency): string { + if (typeof dep.meta.placeholder === 'string') { + return dep.meta.placeholder; + } + + return dep.specifier; +} + +// https://262.ecma-international.org/6.0/#sec-names-and-keywords +const IDENTIFIER_RE = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u; +const ID_START_RE = /^[$_\p{ID_Start}]/u; +const NON_ID_CONTINUE_RE = /[^$_\u200C\u200D\p{ID_Continue}]/gu; + +export function isValidIdentifier(id: string): boolean { + return IDENTIFIER_RE.test(id); +} + +export function makeValidIdentifier(name: string): string { + name = name.replace(NON_ID_CONTINUE_RE, ''); + if (!ID_START_RE.test(name)) { + name = '_' + name; + } + return name; +} diff --git a/packages/packagers/raw-url/package.json b/packages/packagers/raw-url/package.json index 3a1e23736..15acbae60 100644 --- a/packages/packagers/raw-url/package.json +++ b/packages/packagers/raw-url/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/RawUrlPackager.js", - "source": "src/RawUrlPackager.js", + "types": "src/RawUrlPackager.ts", + "source": "src/RawUrlPackager.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/raw-url/src/RawUrlPackager.js b/packages/packagers/raw-url/src/RawUrlPackager.js deleted file mode 100644 index cfd954816..000000000 --- a/packages/packagers/raw-url/src/RawUrlPackager.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; -import {replaceURLReferences} from '@atlaspack/utils'; - -export default (new Packager({ - async package({bundle, bundleGraph}) { - let assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.equal(assets.length, 1, 'Raw bundles must only contain one asset'); - let {contents} = replaceURLReferences({ - bundle, - bundleGraph, - contents: await assets[0].getCode(), - relative: false, - getReplacement: s => s, - }); - return {contents}; - }, -}): Packager); diff --git a/packages/packagers/raw-url/src/RawUrlPackager.ts b/packages/packagers/raw-url/src/RawUrlPackager.ts new file mode 100644 index 000000000..ffaa2d57e --- /dev/null +++ b/packages/packagers/raw-url/src/RawUrlPackager.ts @@ -0,0 +1,22 @@ +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; +import {replaceURLReferences} from '@atlaspack/utils'; + +export default new Packager({ + async package({bundle, bundleGraph}) { + let assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.equal(assets.length, 1, 'Raw bundles must only contain one asset'); + let {contents} = replaceURLReferences({ + bundle, + bundleGraph, + contents: await assets[0].getCode(), + relative: false, + getReplacement: (s) => s, + }); + return {contents}; + }, +}) as Packager; diff --git a/packages/packagers/raw/package.json b/packages/packagers/raw/package.json index 72c3009ce..f6332c121 100644 --- a/packages/packagers/raw/package.json +++ b/packages/packagers/raw/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/RawPackager.js", - "source": "src/RawPackager.js", + "types": "src/RawPackager.ts", + "source": "src/RawPackager.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/raw/src/RawPackager.js b/packages/packagers/raw/src/RawPackager.js deleted file mode 100644 index 60d03f966..000000000 --- a/packages/packagers/raw/src/RawPackager.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; - -export default (new Packager({ - package({bundle}) { - let assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.equal(assets.length, 1, 'Raw bundles must only contain one asset'); - return {contents: assets[0].getStream()}; - }, -}): Packager); diff --git a/packages/packagers/raw/src/RawPackager.ts b/packages/packagers/raw/src/RawPackager.ts new file mode 100644 index 000000000..f8f11a463 --- /dev/null +++ b/packages/packagers/raw/src/RawPackager.ts @@ -0,0 +1,14 @@ +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; + +export default new Packager({ + package({bundle}) { + let assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.equal(assets.length, 1, 'Raw bundles must only contain one asset'); + return {contents: assets[0].getStream()}; + }, +}) as Packager; diff --git a/packages/packagers/svg/package.json b/packages/packagers/svg/package.json index d1c3fb1fd..9989fad7d 100644 --- a/packages/packagers/svg/package.json +++ b/packages/packagers/svg/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/SVGPackager.js", - "source": "src/SVGPackager.js", + "types": "src/SVGPackager.ts", + "source": "src/SVGPackager.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/svg/src/SVGPackager.js b/packages/packagers/svg/src/SVGPackager.js deleted file mode 100644 index 14f67537f..000000000 --- a/packages/packagers/svg/src/SVGPackager.js +++ /dev/null @@ -1,169 +0,0 @@ -// @flow - -import type {Bundle, BundleGraph, NamedBundle} from '@atlaspack/types'; -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; -import posthtml from 'posthtml'; -import { - blobToString, - replaceInlineReferences, - replaceURLReferences, - urlJoin, - setDifference, -} from '@atlaspack/utils'; - -export default (new Packager({ - async package({bundle, bundleGraph, getInlineBundleContents}) { - const assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.strictEqual( - assets.length, - 1, - 'SVG bundles must only contain one asset', - ); - - // Add bundles in the same bundle group that are not inline. For example, if two inline - // bundles refer to the same library that is extracted into a shared bundle. - let referencedBundles = [ - ...setDifference( - new Set(bundleGraph.getReferencedBundles(bundle)), - new Set(bundleGraph.getReferencedBundles(bundle, {recursive: false})), - ), - ]; - - const asset = assets[0]; - const code = await asset.getCode(); - const options = { - directives: [ - { - name: /^\?/, - start: '<', - end: '>', - }, - ], - xmlMode: true, - }; - - let {html: svg} = await posthtml([ - tree => insertBundleReferences(referencedBundles, tree), - tree => - replaceInlineAssetContent(bundleGraph, getInlineBundleContents, tree), - ]).process(code, options); - - const {contents, map} = replaceURLReferences({ - bundle, - bundleGraph, - contents: svg, - relative: false, - getReplacement: contents => contents.replace(/"/g, '"'), - }); - - return replaceInlineReferences({ - bundle, - bundleGraph, - contents, - getInlineBundleContents, - getInlineReplacement: (dep, inlineType, contents) => ({ - from: dep.id, - to: contents.replace(/"/g, '"').trim(), - }), - map, - }); - }, -}): Packager); - -async function replaceInlineAssetContent( - bundleGraph: BundleGraph, - getInlineBundleContents, - tree, -) { - const inlineNodes = []; - tree.walk(node => { - if (node.attrs && node.attrs['data-parcel-key']) { - inlineNodes.push(node); - } - return node; - }); - - for (const node of inlineNodes) { - const newContent = await getAssetContent( - bundleGraph, - getInlineBundleContents, - node.attrs['data-parcel-key'], - ); - - if (newContent === null) { - continue; - } - - node.content = await blobToString(newContent.contents); - - // Wrap scripts and styles with CDATA if needed to ensure characters are not interpreted as XML - if (node.tag === 'script' || node.tag === 'style') { - if (node.content.includes('<') || node.content.includes('&')) { - node.content = node.content.replace(/]]>/g, ']\\]>'); - node.content = ``; - } - } - - // remove attr from output - delete node.attrs['data-parcel-key']; - } - - return tree; -} - -async function getAssetContent( - bundleGraph: BundleGraph, - getInlineBundleContents, - assetId, -) { - let inlineBundle: ?Bundle; - bundleGraph.traverseBundles((bundle, context, {stop}) => { - const entryAssets = bundle.getEntryAssets(); - if (entryAssets.some(a => a.uniqueKey === assetId)) { - inlineBundle = bundle; - stop(); - } - }); - - if (!inlineBundle) { - return null; - } - - const bundleResult = await getInlineBundleContents(inlineBundle, bundleGraph); - - return {bundle: inlineBundle, contents: bundleResult.contents}; -} - -function insertBundleReferences(siblingBundles, tree) { - let scripts = []; - let stylesheets = []; - - for (let bundle of siblingBundles) { - if (bundle.type === 'css') { - stylesheets.push( - ``, - ); - } else if (bundle.type === 'js') { - scripts.push({ - tag: 'script', - attrs: { - href: urlJoin(bundle.target.publicUrl, bundle.name), - }, - }); - } - } - - tree.unshift(...stylesheets); - if (scripts.length > 0) { - tree.match({tag: 'svg'}, node => { - node.content.unshift(...scripts); - }); - } -} diff --git a/packages/packagers/svg/src/SVGPackager.ts b/packages/packagers/svg/src/SVGPackager.ts new file mode 100644 index 000000000..eda19780d --- /dev/null +++ b/packages/packagers/svg/src/SVGPackager.ts @@ -0,0 +1,189 @@ +import type {Bundle, BundleGraph, NamedBundle} from '@atlaspack/types'; +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; +import posthtml from 'posthtml'; +import { + blobToString, + replaceInlineReferences, + replaceURLReferences, + urlJoin, + setDifference, +} from '@atlaspack/utils'; + +export default new Packager({ + async package({bundle, bundleGraph, getInlineBundleContents}) { + const assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.strictEqual( + assets.length, + 1, + 'SVG bundles must only contain one asset', + ); + + // Add bundles in the same bundle group that are not inline. For example, if two inline + // bundles refer to the same library that is extracted into a shared bundle. + let referencedBundles = [ + ...setDifference( + new Set(bundleGraph.getReferencedBundles(bundle)), + new Set(bundleGraph.getReferencedBundles(bundle, {recursive: false})), + ), + ]; + + const asset = assets[0]; + const code = await asset.getCode(); + const options = { + directives: [ + { + name: /^\?/, + start: '<', + end: '>', + }, + ], + xmlMode: true, + } as const; + + let {html: svg} = await posthtml([ + // @ts-expect-error - TS2345 - Argument of type 'unknown[]' is not assignable to parameter of type 'NamedBundle[]'. + (tree: any) => insertBundleReferences(referencedBundles, tree), + (tree: any) => + replaceInlineAssetContent(bundleGraph, getInlineBundleContents, tree), + // @ts-expect-error - TS2559 - Type '{ readonly directives: readonly [{ readonly name: RegExp; readonly start: "<"; readonly end: ">"; }]; readonly xmlMode: true; }' has no properties in common with type 'Options'. + ]).process(code, options); + + const {contents, map} = replaceURLReferences({ + bundle, + bundleGraph, + contents: svg, + relative: false, + getReplacement: (contents) => contents.replace(/"/g, '"'), + }); + + return replaceInlineReferences({ + bundle, + bundleGraph, + contents, + getInlineBundleContents, + getInlineReplacement: (dep, inlineType, contents) => ({ + from: dep.id, + to: contents.replace(/"/g, '"').trim(), + }), + map, + }); + }, +}) as Packager; + +async function replaceInlineAssetContent( + bundleGraph: BundleGraph, + getInlineBundleContents: ( + arg1: Bundle, + arg2: BundleGraph, + ) => Async<{ + contents: Blob; + }>, + tree: any, +) { + const inlineNodes: Array = []; + // @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type. + tree.walk((node) => { + if (node.attrs && node.attrs['data-parcel-key']) { + inlineNodes.push(node); + } + return node; + }); + + for (const node of inlineNodes) { + const newContent = await getAssetContent( + bundleGraph, + getInlineBundleContents, + node.attrs['data-parcel-key'], + ); + + if (newContent === null) { + continue; + } + + node.content = await blobToString(newContent.contents); + + // Wrap scripts and styles with CDATA if needed to ensure characters are not interpreted as XML + if (node.tag === 'script' || node.tag === 'style') { + if (node.content.includes('<') || node.content.includes('&')) { + node.content = node.content.replace(/]]>/g, ']\\]>'); + node.content = ``; + } + } + + // remove attr from output + delete node.attrs['data-parcel-key']; + } + + return tree; +} + +async function getAssetContent( + bundleGraph: BundleGraph, + getInlineBundleContents: ( + arg1: Bundle, + arg2: BundleGraph, + ) => Async<{ + contents: Blob; + }>, + assetId: any, +) { + let inlineBundle: Bundle | null | undefined; + bundleGraph.traverseBundles((bundle, context, {stop}) => { + const entryAssets = bundle.getEntryAssets(); + if (entryAssets.some((a) => a.uniqueKey === assetId)) { + inlineBundle = bundle; + stop(); + } + }); + + if (!inlineBundle) { + return null; + } + + const bundleResult = await getInlineBundleContents(inlineBundle, bundleGraph); + + return {bundle: inlineBundle, contents: bundleResult.contents}; +} + +function insertBundleReferences(siblingBundles: Array, tree: any) { + let scripts: Array< + | any + | { + attrs: { + href: string; + }; + tag: string; + } + > = []; + let stylesheets: Array = []; + + for (let bundle of siblingBundles) { + if (bundle.type === 'css') { + stylesheets.push( + ``, + ); + } else if (bundle.type === 'js') { + scripts.push({ + tag: 'script', + attrs: { + href: urlJoin(bundle.target.publicUrl, bundle.name), + }, + }); + } + } + + tree.unshift(...stylesheets); + if (scripts.length > 0) { + // @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type. + tree.match({tag: 'svg'}, (node) => { + node.content.unshift(...scripts); + }); + } +} diff --git a/packages/packagers/ts/package.json b/packages/packagers/ts/package.json index e530d653f..be80c5556 100644 --- a/packages/packagers/ts/package.json +++ b/packages/packagers/ts/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/TSPackager.js", - "source": "src/TSPackager.js", + "types": "src/TSPackager.ts", + "source": "src/TSPackager.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/ts/src/TSPackager.js b/packages/packagers/ts/src/TSPackager.js deleted file mode 100644 index 34cf05364..000000000 --- a/packages/packagers/ts/src/TSPackager.js +++ /dev/null @@ -1,25 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; - -export default (new Packager({ - async package({bundle, getSourceMapReference}) { - let assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.equal(assets.length, 1, 'TS bundles must only contain one asset'); - let code = await assets[0].getCode(); - let map = await assets[0].getMap(); - if (map) { - let sourcemapReference = await getSourceMapReference(map); - if (sourcemapReference != null) { - code += '\n//# sourceMappingURL=' + sourcemapReference + '\n'; - } - } - - return {contents: code, map}; - }, -}): Packager); diff --git a/packages/packagers/ts/src/TSPackager.ts b/packages/packagers/ts/src/TSPackager.ts new file mode 100644 index 000000000..728cd538a --- /dev/null +++ b/packages/packagers/ts/src/TSPackager.ts @@ -0,0 +1,23 @@ +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; + +export default new Packager({ + async package({bundle, getSourceMapReference}) { + let assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.equal(assets.length, 1, 'TS bundles must only contain one asset'); + let code = await assets[0].getCode(); + let map = await assets[0].getMap(); + if (map) { + let sourcemapReference = await getSourceMapReference(map); + if (sourcemapReference != null) { + code += '\n//# sourceMappingURL=' + sourcemapReference + '\n'; + } + } + + return {contents: code, map}; + }, +}) as Packager; diff --git a/packages/packagers/wasm/package.json b/packages/packagers/wasm/package.json index 2bbc2ab88..979ce2e50 100644 --- a/packages/packagers/wasm/package.json +++ b/packages/packagers/wasm/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/WasmPackager.js", - "source": "src/WasmPackager.js", + "types": "src/WasmPackager.ts", + "source": "src/WasmPackager.ts", "engines": { "node": ">=16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/wasm/src/WasmPackager.js b/packages/packagers/wasm/src/WasmPackager.js deleted file mode 100644 index 33af4ed73..000000000 --- a/packages/packagers/wasm/src/WasmPackager.js +++ /dev/null @@ -1,39 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; -import * as wasmmap from './wasm-sourcemap'; - -export default (new Packager({ - async package({bundle, getSourceMapReference}) { - let assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.equal(assets.length, 1, 'Wasm bundles must only contain one asset'); - - let [contents, map] = await Promise.all([ - assets[0].getBuffer(), - assets[0].getMap(), - ]); - let sourcemapReference = await getSourceMapReference(map); - if (sourcemapReference != null) { - return { - contents: Buffer.from( - wasmmap.SetSourceMapURL( - contents, - sourcemapReference, - sourcemapReference.includes('HASH_REF_') - ? // HASH_REF_\w{16} -> \w{8} - sourcemapReference.length - (9 + 16 - 8) - : undefined, - ), - ), - map, - }; - } else { - return {contents, map}; - } - }, -}): Packager); diff --git a/packages/packagers/wasm/src/WasmPackager.ts b/packages/packagers/wasm/src/WasmPackager.ts new file mode 100644 index 000000000..8cfffa4cf --- /dev/null +++ b/packages/packagers/wasm/src/WasmPackager.ts @@ -0,0 +1,38 @@ +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module './wasm-sourcemap'. '/home/ubuntu/parcel/packages/packagers/wasm/src/wasm-sourcemap.js' implicitly has an 'any' type. +import * as wasmmap from './wasm-sourcemap'; + +export default new Packager({ + async package({bundle, getSourceMapReference}) { + let assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.equal(assets.length, 1, 'Wasm bundles must only contain one asset'); + + let [contents, map] = await Promise.all([ + assets[0].getBuffer(), + assets[0].getMap(), + ]); + let sourcemapReference = await getSourceMapReference(map); + if (sourcemapReference != null) { + return { + contents: Buffer.from( + wasmmap.SetSourceMapURL( + contents, + sourcemapReference, + sourcemapReference.includes('HASH_REF_') + ? // HASH_REF_\w{16} -> \w{8} + sourcemapReference.length - (9 + 16 - 8) + : undefined, + ), + ), + map, + }; + } else { + return {contents, map}; + } + }, +}) as Packager; diff --git a/packages/packagers/webextension/package.json b/packages/packagers/webextension/package.json index b2e2d945d..3a06e5910 100644 --- a/packages/packagers/webextension/package.json +++ b/packages/packagers/webextension/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/WebExtensionPackager.js", - "source": "src/WebExtensionPackager.js", + "types": "src/WebExtensionPackager.ts", + "source": "src/WebExtensionPackager.ts", "engines": { "node": ">=16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/webextension/src/WebExtensionPackager.js b/packages/packagers/webextension/src/WebExtensionPackager.js deleted file mode 100644 index af1c64c94..000000000 --- a/packages/packagers/webextension/src/WebExtensionPackager.js +++ /dev/null @@ -1,108 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import nullthrows from 'nullthrows'; -import {Packager} from '@atlaspack/plugin'; -import {replaceURLReferences, relativeBundlePath} from '@atlaspack/utils'; - -export default (new Packager({ - async package({bundle, bundleGraph}) { - let assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - const manifestAssets = assets.filter(a => a.meta.webextEntry === true); - - assert( - assets.length == 2 && manifestAssets.length == 1, - 'Web extension bundles must contain exactly one manifest asset and one runtime asset', - ); - const asset = manifestAssets[0]; - - const relPath = b => - relativeBundlePath(bundle, b, {leadingDotSlash: false}); - - const manifest = JSON.parse(await asset.getCode()); - - if (manifest.background?.type === 'module') { - // service workers are built with output format 'global' - // see: https://github.com/parcel-bundler/parcel/blob/3329469f50de9326c5b02ef0ab1c0ce41393279c/packages/transformers/js/src/JSTransformer.js#L577 - delete manifest.background.type; - } - - const deps = asset.getDependencies(); - const war = []; - for (const contentScript of manifest.content_scripts || []) { - const srcBundles = deps - .filter( - d => - contentScript.js?.includes(d.id) || - contentScript.css?.includes(d.id), - ) - .map(d => nullthrows(bundleGraph.getReferencedBundle(d, bundle))); - - contentScript.css = [ - ...new Set( - srcBundles - .flatMap(b => bundleGraph.getReferencedBundles(b)) - .filter(b => b.type == 'css') - .map(relPath) - .concat(contentScript.css || []), - ), - ]; - - contentScript.js = [ - ...new Set( - srcBundles - .flatMap(b => bundleGraph.getReferencedBundles(b)) - .filter(b => b.type == 'js') - .map(relPath) - .concat(contentScript.js || []), - ), - ]; - - const resources = srcBundles - .flatMap(b => { - const children = []; - const siblings = bundleGraph.getReferencedBundles(b); - bundleGraph.traverseBundles(child => { - if (b !== child && !siblings.includes(child)) { - children.push(child); - } - }, b); - return children; - }) - .map(relPath); - - if (resources.length > 0) { - war.push({ - matches: contentScript.matches.map(match => { - if (/^(((http|ws)s?)|ftp|\*):\/\//.test(match)) { - let pathIndex = match.indexOf('/', match.indexOf('://') + 3); - // Avoids creating additional errors in invalid match URLs - if (pathIndex == -1) pathIndex = match.length; - return match.slice(0, pathIndex) + '/*'; - } - return match; - }), - resources, - }); - } - } - - const warResult = (manifest.web_accessible_resources || []).concat( - manifest.manifest_version == 2 - ? [...new Set(war.flatMap(entry => entry.resources))] - : war, - ); - - if (warResult.length > 0) manifest.web_accessible_resources = warResult; - - let {contents} = replaceURLReferences({ - bundle, - bundleGraph, - contents: JSON.stringify(manifest), - }); - return {contents}; - }, -}): Packager); diff --git a/packages/packagers/webextension/src/WebExtensionPackager.ts b/packages/packagers/webextension/src/WebExtensionPackager.ts new file mode 100644 index 000000000..8a1acafd9 --- /dev/null +++ b/packages/packagers/webextension/src/WebExtensionPackager.ts @@ -0,0 +1,120 @@ +import assert from 'assert'; +import nullthrows from 'nullthrows'; +import {Packager} from '@atlaspack/plugin'; +import {replaceURLReferences, relativeBundlePath} from '@atlaspack/utils'; + +export default new Packager({ + async package({bundle, bundleGraph}) { + let assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + const manifestAssets = assets.filter((a) => a.meta.webextEntry === true); + + assert( + assets.length == 2 && manifestAssets.length == 1, + 'Web extension bundles must contain exactly one manifest asset and one runtime asset', + ); + const asset = manifestAssets[0]; + + const relPath = (b: NamedBundle) => + relativeBundlePath(bundle, b, {leadingDotSlash: false}); + + const manifest = JSON.parse(await asset.getCode()); + + if (manifest.background?.type === 'module') { + // service workers are built with output format 'global' + // see: https://github.com/parcel-bundler/parcel/blob/3329469f50de9326c5b02ef0ab1c0ce41393279c/packages/transformers/js/src/JSTransformer.js#L577 + delete manifest.background.type; + } + + const deps = asset.getDependencies(); + const war: Array< + | any + | { + matches: never; + resources: Array; + } + > = []; + for (const contentScript of manifest.content_scripts || []) { + const srcBundles = deps + .filter( + // @ts-expect-error - TS7006 - Parameter 'd' implicitly has an 'any' type. + (d) => + contentScript.js?.includes(d.id) || + contentScript.css?.includes(d.id), + ) + // @ts-expect-error - TS7006 - Parameter 'd' implicitly has an 'any' type. + .map((d) => nullthrows(bundleGraph.getReferencedBundle(d, bundle))); + + contentScript.css = [ + ...new Set( + srcBundles + // @ts-expect-error - TS7006 - Parameter 'b' implicitly has an 'any' type. + .flatMap((b) => bundleGraph.getReferencedBundles(b)) + // @ts-expect-error - TS7006 - Parameter 'b' implicitly has an 'any' type. + .filter((b) => b.type == 'css') + .map(relPath) + .concat(contentScript.css || []), + ), + ]; + + contentScript.js = [ + ...new Set( + srcBundles + // @ts-expect-error - TS7006 - Parameter 'b' implicitly has an 'any' type. + .flatMap((b) => bundleGraph.getReferencedBundles(b)) + // @ts-expect-error - TS7006 - Parameter 'b' implicitly has an 'any' type. + .filter((b) => b.type == 'js') + .map(relPath) + .concat(contentScript.js || []), + ), + ]; + + const resources = srcBundles + // @ts-expect-error - TS7006 - Parameter 'b' implicitly has an 'any' type. + .flatMap((b) => { + const children: Array = []; + const siblings = bundleGraph.getReferencedBundles(b); + bundleGraph.traverseBundles((child) => { + if (b !== child && !siblings.includes(child)) { + children.push(child); + } + }, b); + return children; + }) + .map(relPath); + + if (resources.length > 0) { + war.push({ + // @ts-expect-error - TS7006 - Parameter 'match' implicitly has an 'any' type. + matches: contentScript.matches.map((match) => { + if (/^(((http|ws)s?)|ftp|\*):\/\//.test(match)) { + let pathIndex = match.indexOf('/', match.indexOf('://') + 3); + // Avoids creating additional errors in invalid match URLs + if (pathIndex == -1) pathIndex = match.length; + return match.slice(0, pathIndex) + '/*'; + } + return match; + }), + resources, + }); + } + } + + const warResult = (manifest.web_accessible_resources || []).concat( + manifest.manifest_version == 2 + ? [...new Set(war.flatMap((entry) => entry.resources))] + : war, + ); + + if (warResult.length > 0) manifest.web_accessible_resources = warResult; + + let {contents} = replaceURLReferences({ + bundle, + bundleGraph, + contents: JSON.stringify(manifest), + }); + return {contents}; + }, +}) as Packager; diff --git a/packages/packagers/xml/package.json b/packages/packagers/xml/package.json index f3570dd1c..88a5e081a 100644 --- a/packages/packagers/xml/package.json +++ b/packages/packagers/xml/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/XMLPackager.js", - "source": "src/XMLPackager.js", + "types": "src/XMLPackager.ts", + "source": "src/XMLPackager.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/packagers/xml/src/XMLPackager.js b/packages/packagers/xml/src/XMLPackager.js deleted file mode 100644 index 61df5adbf..000000000 --- a/packages/packagers/xml/src/XMLPackager.js +++ /dev/null @@ -1,112 +0,0 @@ -// @flow - -import type {Bundle, BundleGraph, NamedBundle} from '@atlaspack/types'; -import assert from 'assert'; -import {Packager} from '@atlaspack/plugin'; -import { - blobToString, - replaceInlineReferences, - replaceURLReferences, -} from '@atlaspack/utils'; -import {DOMParser, XMLSerializer} from '@xmldom/xmldom'; - -export default (new Packager({ - async package({bundle, bundleGraph, getInlineBundleContents}) { - const assets = []; - bundle.traverseAssets(asset => { - assets.push(asset); - }); - - assert.strictEqual( - assets.length, - 1, - 'XML bundles must only contain one asset', - ); - - let asset = assets[0]; - let code = await asset.getCode(); - let parser = new DOMParser(); - let dom = parser.parseFromString(code); - - let inlineElements = dom.getElementsByTagNameNS( - 'https://parceljs.org', - 'inline', - ); - if (inlineElements.length > 0) { - for (let element of Array.from(inlineElements)) { - let key = element.getAttribute('key'); - let type = element.getAttribute('type'); - - const newContent = await getAssetContent( - bundleGraph, - getInlineBundleContents, - key, - ); - - if (newContent === null) { - continue; - } - - let contents = await blobToString(newContent.contents); - if (type === 'xhtml' || type === 'xml') { - let parsed = new DOMParser().parseFromString( - contents, - 'application/xml', - ); - if (parsed.documentElement != null) { - let parent = element.parentNode; - parent.removeChild(element); - parent.appendChild(parsed.documentElement); - } - } else { - element.parentNode.textContent = contents; - } - } - - code = new XMLSerializer().serializeToString(dom); - } - - const {contents, map} = replaceURLReferences({ - bundle, - bundleGraph, - contents: code, - relative: false, - getReplacement: contents => contents.replace(/"/g, '"'), - }); - - return replaceInlineReferences({ - bundle, - bundleGraph, - contents, - getInlineBundleContents, - getInlineReplacement: (dep, inlineType, contents) => ({ - from: dep.id, - to: contents.replace(/"/g, '"').trim(), - }), - map, - }); - }, -}): Packager); - -async function getAssetContent( - bundleGraph: BundleGraph, - getInlineBundleContents, - assetId, -) { - let inlineBundle: ?Bundle; - bundleGraph.traverseBundles((bundle, context, {stop}) => { - const entryAssets = bundle.getEntryAssets(); - if (entryAssets.some(a => a.uniqueKey === assetId)) { - inlineBundle = bundle; - stop(); - } - }); - - if (!inlineBundle) { - return null; - } - - const bundleResult = await getInlineBundleContents(inlineBundle, bundleGraph); - - return {bundle: inlineBundle, contents: bundleResult.contents}; -} diff --git a/packages/packagers/xml/src/XMLPackager.ts b/packages/packagers/xml/src/XMLPackager.ts new file mode 100644 index 000000000..0ba1896c4 --- /dev/null +++ b/packages/packagers/xml/src/XMLPackager.ts @@ -0,0 +1,118 @@ +import type {Bundle, BundleGraph, NamedBundle} from '@atlaspack/types'; +import assert from 'assert'; +import {Packager} from '@atlaspack/plugin'; +import { + blobToString, + replaceInlineReferences, + replaceURLReferences, +} from '@atlaspack/utils'; +import {DOMParser, XMLSerializer} from '@xmldom/xmldom'; + +export default new Packager({ + async package({bundle, bundleGraph, getInlineBundleContents}) { + const assets: Array = []; + bundle.traverseAssets((asset) => { + assets.push(asset); + }); + + assert.strictEqual( + assets.length, + 1, + 'XML bundles must only contain one asset', + ); + + let asset = assets[0]; + let code = await asset.getCode(); + let parser = new DOMParser(); + let dom = parser.parseFromString(code); + + let inlineElements = dom.getElementsByTagNameNS( + 'https://parceljs.org', + 'inline', + ); + if (inlineElements.length > 0) { + for (let element of Array.from(inlineElements)) { + let key = element.getAttribute('key'); + let type = element.getAttribute('type'); + + const newContent = await getAssetContent( + bundleGraph, + getInlineBundleContents, + key, + ); + + if (newContent === null) { + continue; + } + + let contents = await blobToString(newContent.contents); + if (type === 'xhtml' || type === 'xml') { + let parsed = new DOMParser().parseFromString( + contents, + 'application/xml', + ); + if (parsed.documentElement != null) { + let parent = element.parentNode; + // @ts-expect-error - TS2531 - Object is possibly 'null'. + parent.removeChild(element); + // @ts-expect-error - TS2531 - Object is possibly 'null'. + parent.appendChild(parsed.documentElement); + } + } else { + // @ts-expect-error - TS2531 - Object is possibly 'null'. + element.parentNode.textContent = contents; + } + } + + code = new XMLSerializer().serializeToString(dom); + } + + const {contents, map} = replaceURLReferences({ + bundle, + bundleGraph, + contents: code, + relative: false, + getReplacement: (contents) => contents.replace(/"/g, '"'), + }); + + return replaceInlineReferences({ + bundle, + bundleGraph, + contents, + getInlineBundleContents, + getInlineReplacement: (dep, inlineType, contents) => ({ + from: dep.id, + to: contents.replace(/"/g, '"').trim(), + }), + map, + }); + }, +}) as Packager; + +async function getAssetContent( + bundleGraph: BundleGraph, + getInlineBundleContents: ( + arg1: Bundle, + arg2: BundleGraph, + ) => Async<{ + contents: Blob; + }>, + assetId: any, +) { + let inlineBundle: Bundle | null | undefined; + bundleGraph.traverseBundles((bundle, context, {stop}) => { + const entryAssets = bundle.getEntryAssets(); + if (entryAssets.some((a) => a.uniqueKey === assetId)) { + inlineBundle = bundle; + stop(); + } + }); + + if (!inlineBundle) { + return null; + } + + const bundleResult = await getInlineBundleContents(inlineBundle, bundleGraph); + + return {bundle: inlineBundle, contents: bundleResult.contents}; +} diff --git a/packages/reporters/build-metrics/package.json b/packages/reporters/build-metrics/package.json index 3e30fb3a2..ec477df3e 100644 --- a/packages/reporters/build-metrics/package.json +++ b/packages/reporters/build-metrics/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/BuildMetricsReporter.js", - "source": "src/BuildMetricsReporter.js", + "types": "src/BuildMetricsReporter.ts", + "source": "src/BuildMetricsReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/build-metrics/src/BuildMetricsReporter.js b/packages/reporters/build-metrics/src/BuildMetricsReporter.js deleted file mode 100644 index 25148e75b..000000000 --- a/packages/reporters/build-metrics/src/BuildMetricsReporter.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow strict-local -import path from 'path'; - -import {Reporter} from '@atlaspack/plugin'; -import {generateBuildMetrics} from '@atlaspack/utils'; - -type TimingValue = {| - timings: {[key: string]: number, ...}, - lastPhase: string, -|}; - -let timingsMap = new Map(); -const getValue = (instanceId: string): TimingValue => { - if (!timingsMap.has(instanceId)) { - timingsMap.set(instanceId, { - timings: {}, - lastPhase: 'resolving', - }); - } - - // $FlowFixMe - return timingsMap.get(instanceId); -}; - -export default (new Reporter({ - async report({event, options}) { - if (event.type === 'buildProgress') { - let value = getValue(options.instanceId); - - value.timings[event.phase] = Date.now(); - if (value.lastPhase !== event.phase) { - value.timings[value.lastPhase] = - Date.now() - value.timings[value.lastPhase]; - } - value.lastPhase = event.phase; - } else if (event.type === 'buildSuccess') { - let value = getValue(options.instanceId); - - value.timings[value.lastPhase] = - Date.now() - value.timings[value.lastPhase]; - let metricsFilePath = path.join( - options.projectRoot, - 'parcel-metrics.json', - ); - - let {bundles} = await generateBuildMetrics( - event.bundleGraph.getBundles(), - options.outputFS, - options.projectRoot, - ); - - let metrics = { - phaseTimings: value.timings, - buildTime: event.buildTime, - bundles: bundles.map(b => { - return { - filePath: b.filePath, - size: b.size, - time: b.time, - largestAssets: b.assets.slice(0, 10), - totalAssets: b.assets.length, - }; - }), - }; - - await options.outputFS.writeFile( - metricsFilePath, - JSON.stringify(metrics), - ); - } - }, -}): Reporter); diff --git a/packages/reporters/build-metrics/src/BuildMetricsReporter.ts b/packages/reporters/build-metrics/src/BuildMetricsReporter.ts new file mode 100644 index 000000000..e0ff4d3d2 --- /dev/null +++ b/packages/reporters/build-metrics/src/BuildMetricsReporter.ts @@ -0,0 +1,72 @@ +import path from 'path'; + +import {Reporter} from '@atlaspack/plugin'; +import {generateBuildMetrics} from '@atlaspack/utils'; + +type TimingValue = { + timings: { + [key: string]: number; + }; + lastPhase: string; +}; + +let timingsMap = new Map(); +const getValue = (instanceId: string): TimingValue => { + if (!timingsMap.has(instanceId)) { + timingsMap.set(instanceId, { + timings: {}, + lastPhase: 'resolving', + }); + } + + return timingsMap.get(instanceId); +}; + +export default new Reporter({ + async report({event, options}) { + if (event.type === 'buildProgress') { + let value = getValue(options.instanceId); + + value.timings[event.phase] = Date.now(); + if (value.lastPhase !== event.phase) { + value.timings[value.lastPhase] = + Date.now() - value.timings[value.lastPhase]; + } + value.lastPhase = event.phase; + } else if (event.type === 'buildSuccess') { + let value = getValue(options.instanceId); + + value.timings[value.lastPhase] = + Date.now() - value.timings[value.lastPhase]; + let metricsFilePath = path.join( + options.projectRoot, + 'parcel-metrics.json', + ); + + let {bundles} = await generateBuildMetrics( + event.bundleGraph.getBundles(), + options.outputFS, + options.projectRoot, + ); + + let metrics = { + phaseTimings: value.timings, + buildTime: event.buildTime, + bundles: bundles.map((b) => { + return { + filePath: b.filePath, + size: b.size, + time: b.time, + largestAssets: b.assets.slice(0, 10), + totalAssets: b.assets.length, + }; + }), + }; + + await options.outputFS.writeFile( + metricsFilePath, + JSON.stringify(metrics), + ); + } + }, +}) as Reporter; diff --git a/packages/reporters/bundle-analyzer/client/index.js b/packages/reporters/bundle-analyzer/client/index.js index b1d46c58b..0debf3f29 100644 --- a/packages/reporters/bundle-analyzer/client/index.js +++ b/packages/reporters/bundle-analyzer/client/index.js @@ -64,7 +64,7 @@ let foamtree = new CarrotSearchFoamTree({ }, }); -visualization.addEventListener('mousemove', e => { +visualization.addEventListener('mousemove', (e) => { if (tooltip == null) { return; } diff --git a/packages/reporters/bundle-analyzer/index.js b/packages/reporters/bundle-analyzer/index.js deleted file mode 100644 index b7d10a23a..000000000 --- a/packages/reporters/bundle-analyzer/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow strict-local - -export * from './src/BundleAnalyzerReporter'; diff --git a/packages/reporters/bundle-analyzer/index.ts b/packages/reporters/bundle-analyzer/index.ts new file mode 100644 index 000000000..7022a75ef --- /dev/null +++ b/packages/reporters/bundle-analyzer/index.ts @@ -0,0 +1 @@ +export * from './src/BundleAnalyzerReporter'; diff --git a/packages/reporters/bundle-analyzer/package.json b/packages/reporters/bundle-analyzer/package.json index 7f3b921d4..da7b5d640 100644 --- a/packages/reporters/bundle-analyzer/package.json +++ b/packages/reporters/bundle-analyzer/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/BundleAnalyzerReporter.js", - "source": "src/BundleAnalyzerReporter.js", + "types": "src/BundleAnalyzerReporter.ts", + "source": "src/BundleAnalyzerReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.js b/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.js deleted file mode 100644 index d3d7beebe..000000000 --- a/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.js +++ /dev/null @@ -1,192 +0,0 @@ -// @flow strict-local - -import type {FilePath, PackagedBundle, PluginOptions} from '@atlaspack/types'; - -import invariant from 'assert'; -import {Reporter} from '@atlaspack/plugin'; -import {DefaultMap, generateBuildMetrics} from '@atlaspack/utils'; -import path from 'path'; -import nullthrows from 'nullthrows'; - -export default (new Reporter({ - async report({event, options}) { - if (event.type !== 'buildSuccess') { - return; - } - - let bundlesByTarget: DefaultMap< - string /* target name */, - Array, - > = new DefaultMap(() => []); - for (let bundle of event.bundleGraph.getBundles()) { - bundlesByTarget.get(bundle.target.name).push(bundle); - } - - let reportsDir = path.join(options.projectRoot, 'parcel-bundle-reports'); - await options.outputFS.mkdirp(reportsDir); - - await Promise.all( - [...bundlesByTarget.entries()].map(async ([targetName, bundles]) => { - return options.outputFS.writeFile( - path.join(reportsDir, `${targetName}.html`), - ` - - - - Atlaspack Bundle Analyzer | ${targetName} - - - - - - - - - `, - ); - }), - ); - }, -}): Reporter); - -type BundleData = {| - groups: Array, -|}; - -async function getBundleData( - bundles: Array, - options: PluginOptions, -): Promise { - let groups = await Promise.all( - bundles.map(bundle => getBundleNode(bundle, options)), - ); - return { - groups, - }; -} - -type File = {| - basename: string, - size: number, -|}; -type DirMapValue = File | DirMap; -type DirMap = DefaultMap; -let createMap: () => DirMap = () => new DefaultMap(() => createMap()); - -async function getBundleNode(bundle: PackagedBundle, options: PluginOptions) { - let buildMetrics = await generateBuildMetrics( - [bundle], - options.outputFS, - options.projectRoot, - ); - let bundleData = buildMetrics.bundles[0]; - let dirMap = createMap(); - for (let asset of bundleData.assets) { - let relativePath = path.relative(options.projectRoot, asset.filePath); - let parts = relativePath.split(path.sep); - let dirs = parts.slice(0, parts.length - 1); - let basename = path.basename(asset.filePath); - - let map = dirMap; - for (let dir of dirs) { - invariant(map instanceof DefaultMap); - map = map.get(dir); - } - - invariant(map instanceof DefaultMap); - map.set(basename, { - basename, - size: asset.size, - }); - } - - return { - label: path.relative(options.projectRoot, bundle.filePath), - weight: bundle.stats.size, - groups: generateGroups(dirMap), - }; -} - -type Group = {| - label: string, - weight: number, - groups?: Array, -|}; - -function generateGroups(dirMap: DirMap): Array { - let groups = []; - - for (let [directoryName, contents] of dirMap) { - if (contents instanceof DefaultMap) { - let childrenGroups = generateGroups(contents); - if (childrenGroups.length === 1) { - let firstChild = childrenGroups[0]; - groups.push({ - ...firstChild, - label: path.join(directoryName, firstChild.label), - }); - } else { - groups.push({ - label: directoryName, - weight: childrenGroups.reduce( - (acc, g) => acc + nullthrows(g.weight), - 0, - ), - groups: childrenGroups, - }); - } - } else { - // file - groups.push({ - label: - contents.basename === '' - ? 'Code from unknown source files' - : contents.basename, - weight: contents.size, - }); - } - } - - return groups; -} diff --git a/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.ts b/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.ts new file mode 100644 index 000000000..4efec046f --- /dev/null +++ b/packages/reporters/bundle-analyzer/src/BundleAnalyzerReporter.ts @@ -0,0 +1,193 @@ +import type {FilePath, PackagedBundle, PluginOptions} from '@atlaspack/types'; + +import invariant from 'assert'; +import {Reporter} from '@atlaspack/plugin'; +import {DefaultMap, generateBuildMetrics} from '@atlaspack/utils'; +import path from 'path'; +import nullthrows from 'nullthrows'; + +export default new Reporter({ + async report({event, options}) { + if (event.type !== 'buildSuccess') { + return; + } + + let bundlesByTarget: DefaultMap< + string /* target name */, + Array + > = new DefaultMap(() => []); + for (let bundle of event.bundleGraph.getBundles()) { + bundlesByTarget.get(bundle.target.name).push(bundle); + } + + let reportsDir = path.join(options.projectRoot, 'parcel-bundle-reports'); + await options.outputFS.mkdirp(reportsDir); + + await Promise.all( + [...bundlesByTarget.entries()].map( + async ([targetName, bundles]: [any, any]) => { + return options.outputFS.writeFile( + path.join(reportsDir, `${targetName}.html`), + ` + + + + Atlaspack Bundle Analyzer | ${targetName} + + + + + + + + + `, + ); + }, + ), + ); + }, +}) as Reporter; + +type BundleData = { + groups: Array; +}; + +async function getBundleData( + bundles: Array, + options: PluginOptions, +): Promise { + let groups = await Promise.all( + bundles.map((bundle) => getBundleNode(bundle, options)), + ); + return { + groups, + }; +} + +type File = { + basename: string; + size: number; +}; +type DirMapValue = File | DirMap; +type DirMap = DefaultMap; +let createMap: () => DirMap = () => new DefaultMap(() => createMap()); + +async function getBundleNode(bundle: PackagedBundle, options: PluginOptions) { + let buildMetrics = await generateBuildMetrics( + [bundle], + options.outputFS, + options.projectRoot, + ); + let bundleData = buildMetrics.bundles[0]; + let dirMap = createMap(); + for (let asset of bundleData.assets) { + let relativePath = path.relative(options.projectRoot, asset.filePath); + let parts = relativePath.split(path.sep); + let dirs = parts.slice(0, parts.length - 1); + let basename = path.basename(asset.filePath); + + let map = dirMap; + for (let dir of dirs) { + invariant(map instanceof DefaultMap); + // @ts-expect-error - TS2322 - Type 'DirMapValue' is not assignable to type 'DirMap'. + map = map.get(dir); + } + + invariant(map instanceof DefaultMap); + map.set(basename, { + basename, + size: asset.size, + }); + } + + return { + label: path.relative(options.projectRoot, bundle.filePath), + weight: bundle.stats.size, + groups: generateGroups(dirMap), + }; +} + +type Group = { + label: string; + weight: number; + groups?: Array; +}; + +function generateGroups(dirMap: DirMap): Array { + let groups: Array = []; + + for (let [directoryName, contents] of dirMap) { + if (contents instanceof DefaultMap) { + let childrenGroups = generateGroups(contents); + if (childrenGroups.length === 1) { + let firstChild = childrenGroups[0]; + groups.push({ + ...firstChild, + label: path.join(directoryName, firstChild.label), + }); + } else { + groups.push({ + label: directoryName, + weight: childrenGroups.reduce( + (acc, g) => acc + nullthrows(g.weight), + 0, + ), + groups: childrenGroups, + }); + } + } else { + // file + groups.push({ + label: + contents.basename === '' + ? 'Code from unknown source files' + : contents.basename, + weight: contents.size, + }); + } + } + + return groups; +} diff --git a/packages/reporters/bundle-buddy/package.json b/packages/reporters/bundle-buddy/package.json index 4bcaf190e..e1636486b 100644 --- a/packages/reporters/bundle-buddy/package.json +++ b/packages/reporters/bundle-buddy/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/BundleBuddyReporter.js", - "source": "src/BundleBuddyReporter.js", + "types": "src/BundleBuddyReporter.ts", + "source": "src/BundleBuddyReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/bundle-buddy/src/BundleBuddyReporter.js b/packages/reporters/bundle-buddy/src/BundleBuddyReporter.js deleted file mode 100644 index c5670335f..000000000 --- a/packages/reporters/bundle-buddy/src/BundleBuddyReporter.js +++ /dev/null @@ -1,55 +0,0 @@ -// @flow strict-local -import type {PackagedBundle} from '@atlaspack/types'; -import {Reporter} from '@atlaspack/plugin'; -import path from 'path'; - -export default (new Reporter({ - async report({event, options, logger}) { - if (event.type !== 'buildSuccess') { - return; - } - - let bundlesByTarget: Map> = new Map(); - for (let bundle of event.bundleGraph.getBundles()) { - let bundles = bundlesByTarget.get(bundle.target.distDir); - if (!bundles) { - bundles = []; - bundlesByTarget.set(bundle.target.distDir, bundles); - } - - bundles.push(bundle); - } - - for (let [targetDir, bundles] of bundlesByTarget) { - let out = []; - - for (let bundle of bundles) { - bundle.traverseAssets(asset => { - let deps = event.bundleGraph.getDependencies(asset); - for (let dep of deps) { - let resolved = event.bundleGraph.getResolvedAsset(dep); - if (!resolved) { - continue; - } - - out.push({ - source: path.relative(options.projectRoot, asset.filePath), - target: path.relative(options.projectRoot, resolved.filePath), - }); - } - }); - } - - await options.outputFS.writeFile( - path.join(targetDir, 'bundle-buddy.json'), - JSON.stringify(out), - ); - logger.info({ - message: `Wrote report to ${path.relative( - options.outputFS.cwd(), - path.join(targetDir, 'bundle-buddy.json'), - )}`, - }); - } - }, -}): Reporter); diff --git a/packages/reporters/bundle-buddy/src/BundleBuddyReporter.ts b/packages/reporters/bundle-buddy/src/BundleBuddyReporter.ts new file mode 100644 index 000000000..b3c74d6d8 --- /dev/null +++ b/packages/reporters/bundle-buddy/src/BundleBuddyReporter.ts @@ -0,0 +1,57 @@ +import type {PackagedBundle} from '@atlaspack/types'; +import {Reporter} from '@atlaspack/plugin'; +import path from 'path'; + +export default new Reporter({ + async report({event, options, logger}) { + if (event.type !== 'buildSuccess') { + return; + } + + let bundlesByTarget: Map> = new Map(); + for (let bundle of event.bundleGraph.getBundles()) { + let bundles = bundlesByTarget.get(bundle.target.distDir); + if (!bundles) { + bundles = []; + bundlesByTarget.set(bundle.target.distDir, bundles); + } + + bundles.push(bundle); + } + + for (let [targetDir, bundles] of bundlesByTarget) { + let out: Array<{ + source: string; + target: string; + }> = []; + + for (let bundle of bundles) { + bundle.traverseAssets((asset) => { + let deps = event.bundleGraph.getDependencies(asset); + for (let dep of deps) { + let resolved = event.bundleGraph.getResolvedAsset(dep); + if (!resolved) { + continue; + } + + out.push({ + source: path.relative(options.projectRoot, asset.filePath), + target: path.relative(options.projectRoot, resolved.filePath), + }); + } + }); + } + + await options.outputFS.writeFile( + path.join(targetDir, 'bundle-buddy.json'), + JSON.stringify(out), + ); + logger.info({ + message: `Wrote report to ${path.relative( + options.outputFS.cwd(), + path.join(targetDir, 'bundle-buddy.json'), + )}`, + }); + } + }, +}) as Reporter; diff --git a/packages/reporters/bundle-stats/package.json b/packages/reporters/bundle-stats/package.json index dceefe24e..396ff6ad1 100644 --- a/packages/reporters/bundle-stats/package.json +++ b/packages/reporters/bundle-stats/package.json @@ -5,7 +5,8 @@ "access": "public" }, "main": "lib/BundleStatsReporter.js", - "source": "src/BundleStatsReporter.js", + "types": "src/BundleStatsReporter.ts", + "source": "src/BundleStatsReporter.ts", "bin": { "atlaspack-bundle-stats": "bin.js" }, diff --git a/packages/reporters/bundle-stats/src/BundleStatsReporter.js b/packages/reporters/bundle-stats/src/BundleStatsReporter.js deleted file mode 100644 index cfd97e681..000000000 --- a/packages/reporters/bundle-stats/src/BundleStatsReporter.js +++ /dev/null @@ -1,99 +0,0 @@ -// @flow strict-local - -import type {PackagedBundle, PluginOptions} from '@atlaspack/types'; - -import {Reporter} from '@atlaspack/plugin'; -import {DefaultMap} from '@atlaspack/utils'; - -import assert from 'assert'; -import path from 'path'; - -export type AssetStat = {| - size: number, - name: string, - bundles: Array, -|}; - -export type BundleStat = {| - size: number, - id: string, - assets: Array, -|}; - -export type BundleStats = {| - bundles: {[key: string]: BundleStat}, - assets: {[key: string]: AssetStat}, -|}; - -export default (new Reporter({ - async report({event, options}) { - if (event.type !== 'buildSuccess') { - return; - } - - let bundlesByTarget: DefaultMap< - string /* target name */, - Array, - > = new DefaultMap(() => []); - for (let bundle of event.bundleGraph.getBundles()) { - bundlesByTarget.get(bundle.target.name).push(bundle); - } - - let reportsDir = path.join(options.projectRoot, 'parcel-bundle-reports'); - await options.outputFS.mkdirp(reportsDir); - - await Promise.all( - [...bundlesByTarget.entries()].map(([targetName, bundles]) => - options.outputFS.writeFile( - path.join(reportsDir, `${targetName}-stats.json`), - JSON.stringify(getBundleStats(bundles, options), null, 2), - ), - ), - ); - }, -}): Reporter); - -export function getBundleStats( - bundles: Array, - options: PluginOptions, -): BundleStats { - let bundlesByName = new Map(); - let assetsById = new Map(); - - // let seen = new Map(); - - for (let bundle of bundles) { - let bundleName = path.relative(options.projectRoot, bundle.filePath); - - // If we've already seen this bundle, we can skip it... right? - if (bundlesByName.has(bundleName)) { - // Sanity check: this is the same bundle, right? - assert(bundlesByName.get(bundleName)?.size === bundle.stats.size); - continue; - } - - let assets = []; - bundle.traverseAssets(({id, filePath, stats: {size}}) => { - assets.push(id); - let assetName = path.relative(options.projectRoot, filePath); - if (assetsById.has(id)) { - assert(assetsById.get(id)?.name === assetName); - assert(assetsById.get(id)?.size === size); - assetsById.get(id)?.bundles.push(bundleName); - } else { - assetsById.set(id, {name: assetName, size, bundles: [bundleName]}); - } - }); - - bundlesByName.set(bundleName, { - id: bundle.id, - size: bundle.stats.size, - assets, - }); - } - - return { - bundles: Object.fromEntries(bundlesByName), - assets: Object.fromEntries(assetsById), - }; -} diff --git a/packages/reporters/bundle-stats/src/BundleStatsReporter.ts b/packages/reporters/bundle-stats/src/BundleStatsReporter.ts new file mode 100644 index 000000000..5cfcc9582 --- /dev/null +++ b/packages/reporters/bundle-stats/src/BundleStatsReporter.ts @@ -0,0 +1,101 @@ +import type {PackagedBundle, PluginOptions} from '@atlaspack/types'; + +import {Reporter} from '@atlaspack/plugin'; +import {DefaultMap} from '@atlaspack/utils'; + +import assert from 'assert'; +import path from 'path'; + +export type AssetStat = { + size: number; + name: string; + bundles: Array; +}; + +export type BundleStat = { + size: number; + id: string; + assets: Array; +}; + +export type BundleStats = { + bundles: { + [key: string]: BundleStat; + }; + assets: { + [key: string]: AssetStat; + }; +}; + +export default new Reporter({ + async report({event, options}) { + if (event.type !== 'buildSuccess') { + return; + } + + let bundlesByTarget: DefaultMap< + string /* target name */, + Array + > = new DefaultMap(() => []); + for (let bundle of event.bundleGraph.getBundles()) { + bundlesByTarget.get(bundle.target.name).push(bundle); + } + + let reportsDir = path.join(options.projectRoot, 'parcel-bundle-reports'); + await options.outputFS.mkdirp(reportsDir); + + await Promise.all( + [...bundlesByTarget.entries()].map(([targetName, bundles]: [any, any]) => + options.outputFS.writeFile( + path.join(reportsDir, `${targetName}-stats.json`), + JSON.stringify(getBundleStats(bundles, options), null, 2), + ), + ), + ); + }, +}) as Reporter; + +export function getBundleStats( + bundles: Array, + options: PluginOptions, +): BundleStats { + let bundlesByName = new Map(); + let assetsById = new Map(); + + // let seen = new Map(); + + for (let bundle of bundles) { + let bundleName = path.relative(options.projectRoot, bundle.filePath); + + // If we've already seen this bundle, we can skip it... right? + if (bundlesByName.has(bundleName)) { + // Sanity check: this is the same bundle, right? + assert(bundlesByName.get(bundleName)?.size === bundle.stats.size); + continue; + } + + let assets: Array = []; + bundle.traverseAssets(({id, filePath, stats: {size}}) => { + assets.push(id); + let assetName = path.relative(options.projectRoot, filePath); + if (assetsById.has(id)) { + assert(assetsById.get(id)?.name === assetName); + assert(assetsById.get(id)?.size === size); + assetsById.get(id)?.bundles.push(bundleName); + } else { + assetsById.set(id, {name: assetName, size, bundles: [bundleName]}); + } + }); + + bundlesByName.set(bundleName, { + id: bundle.id, + size: bundle.stats.size, + assets, + }); + } + + return { + bundles: Object.fromEntries(bundlesByName), + assets: Object.fromEntries(assetsById), + }; +} diff --git a/packages/reporters/cli/package.json b/packages/reporters/cli/package.json index 71f3185c5..319692925 100644 --- a/packages/reporters/cli/package.json +++ b/packages/reporters/cli/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/CLIReporter.js", - "source": "src/CLIReporter.js", + "types": "src/CLIReporter.ts", + "source": "src/CLIReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/cli/src/CLIReporter.js b/packages/reporters/cli/src/CLIReporter.js deleted file mode 100644 index 8f8f43b04..000000000 --- a/packages/reporters/cli/src/CLIReporter.js +++ /dev/null @@ -1,298 +0,0 @@ -// @flow -import type {ReporterEvent, PluginOptions} from '@atlaspack/types'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import type {Color} from 'chalk'; - -import {Reporter} from '@atlaspack/plugin'; -import { - getProgressMessage, - prettifyTime, - prettyDiagnostic, - throttle, -} from '@atlaspack/utils'; -import chalk from 'chalk'; - -import {getTerminalWidth} from './utils'; -import logLevels from './logLevels'; -import bundleReport from './bundleReport'; -import phaseReport from './phaseReport'; -import { - writeOut, - updateSpinner, - persistSpinner, - isTTY, - resetWindow, - persistMessage, -} from './render'; -import * as emoji from './emoji'; -import wrapAnsi from 'wrap-ansi'; - -const THROTTLE_DELAY = 100; -const seenWarnings = new Set(); -const seenPhases = new Set(); -const seenPhasesGen = new Set(); - -let phaseStartTimes = {}; -let pendingIncrementalBuild = false; - -let statusThrottle = throttle((message: string) => { - updateSpinner(message); -}, THROTTLE_DELAY); - -// Exported only for test -export async function _report( - event: ReporterEvent, - options: PluginOptions, -): Promise { - let logLevelFilter = logLevels[options.logLevel || 'info']; - - switch (event.type) { - case 'buildStart': { - seenWarnings.clear(); - seenPhases.clear(); - if (logLevelFilter < logLevels.info) { - break; - } - - // Clear any previous output - resetWindow(); - - if (options.serveOptions) { - persistMessage( - chalk.blue.bold( - `Server running at ${ - options.serveOptions.https ? 'https' : 'http' - }://${options.serveOptions.host ?? 'localhost'}:${ - options.serveOptions.port - }`, - ), - ); - } - - break; - } - case 'buildProgress': { - if (logLevelFilter < logLevels.info) { - break; - } - - if (pendingIncrementalBuild) { - pendingIncrementalBuild = false; - phaseStartTimes = {}; - seenPhasesGen.clear(); - seenPhases.clear(); - } - - if (!seenPhasesGen.has(event.phase)) { - phaseStartTimes[event.phase] = Date.now(); - seenPhasesGen.add(event.phase); - } - - if (!isTTY && logLevelFilter != logLevels.verbose) { - if (event.phase == 'transforming' && !seenPhases.has('transforming')) { - updateSpinner('Building...'); - } else if (event.phase == 'bundling' && !seenPhases.has('bundling')) { - updateSpinner('Bundling...'); - } else if ( - (event.phase == 'packaging' || event.phase == 'optimizing') && - !seenPhases.has('packaging') && - !seenPhases.has('optimizing') - ) { - updateSpinner('Packaging & Optimizing...'); - } - seenPhases.add(event.phase); - break; - } - - let message = getProgressMessage(event); - if (message != null) { - if (isTTY) { - statusThrottle(chalk.gray.bold(message)); - } else { - updateSpinner(message); - } - } - break; - } - case 'buildSuccess': - if (logLevelFilter < logLevels.info) { - break; - } - - phaseStartTimes['buildSuccess'] = Date.now(); - - persistSpinner( - 'buildProgress', - 'success', - chalk.green.bold(`Built in ${prettifyTime(event.buildTime)}`), - ); - - if (options.mode === 'production') { - await bundleReport( - event.bundleGraph, - options.outputFS, - options.projectRoot, - options.detailedReport?.assetsPerBundle, - ); - } else { - pendingIncrementalBuild = true; - } - - if (process.env.ATLASPACK_SHOW_PHASE_TIMES) { - phaseReport(phaseStartTimes); - } - break; - case 'buildFailure': - if (logLevelFilter < logLevels.error) { - break; - } - - resetWindow(); - - persistSpinner('buildProgress', 'error', chalk.red.bold('Build failed.')); - - await writeDiagnostic(options, event.diagnostics, 'red', true); - break; - case 'cache': - if (event.size > 500000) { - switch (event.phase) { - case 'start': - updateSpinner('Writing cache to disk'); - break; - case 'end': - persistSpinner( - 'cache', - 'success', - chalk.grey.bold(`Cache written to disk`), - ); - break; - } - } - break; - case 'log': { - if (logLevelFilter < logLevels[event.level]) { - break; - } - - switch (event.level) { - case 'success': - writeOut(chalk.green(event.message)); - break; - case 'progress': - writeOut(event.message); - break; - case 'verbose': - case 'info': - await writeDiagnostic(options, event.diagnostics, 'blue'); - break; - case 'warn': - if ( - event.diagnostics.some( - diagnostic => !seenWarnings.has(diagnostic.message), - ) - ) { - await writeDiagnostic(options, event.diagnostics, 'yellow', true); - for (let diagnostic of event.diagnostics) { - seenWarnings.add(diagnostic.message); - } - } - break; - case 'error': - await writeDiagnostic(options, event.diagnostics, 'red', true); - break; - default: - throw new Error('Unknown log level ' + event.level); - } - } - } -} - -async function writeDiagnostic( - options: PluginOptions, - diagnostics: Array, - color: Color, - isError: boolean = false, -) { - let columns = getTerminalWidth().columns; - let indent = 2; - let spaceAfter = isError; - for (let diagnostic of diagnostics) { - let {message, stack, codeframe, hints, documentation} = - await prettyDiagnostic(diagnostic, options, columns - indent); - // $FlowFixMe[incompatible-use] - message = chalk[color](message); - - if (spaceAfter) { - writeOut(''); - } - - if (message) { - writeOut(wrapWithIndent(message), isError); - } - - if (stack || codeframe) { - writeOut(''); - } - - if (stack) { - writeOut(chalk.gray(wrapWithIndent(stack, indent)), isError); - } - - if (codeframe) { - writeOut(indentString(codeframe, indent), isError); - } - - if ((stack || codeframe) && (hints.length > 0 || documentation)) { - writeOut(''); - } - - // Write hints - let hintIndent = stack || codeframe ? indent : 0; - for (let hint of hints) { - writeOut( - wrapWithIndent( - `${emoji.hint} ${chalk.blue.bold(hint)}`, - hintIndent + 3, - hintIndent, - ), - ); - } - - if (documentation) { - writeOut( - wrapWithIndent( - `${emoji.docs} ${chalk.magenta.bold(documentation)}`, - hintIndent + 3, - hintIndent, - ), - ); - } - - spaceAfter = stack || codeframe || hints.length > 0 || documentation; - } - - if (spaceAfter) { - writeOut(''); - } -} - -function wrapWithIndent(string, indent = 0, initialIndent = indent) { - let width = getTerminalWidth().columns; - return indentString( - wrapAnsi(string.trimEnd(), width - indent, {trim: false}), - indent, - initialIndent, - ); -} - -function indentString(string, indent = 0, initialIndent = indent) { - return ( - ' '.repeat(initialIndent) + string.replace(/\n/g, '\n' + ' '.repeat(indent)) - ); -} - -export default (new Reporter({ - report({event, options}) { - return _report(event, options); - }, -}): Reporter); diff --git a/packages/reporters/cli/src/CLIReporter.ts b/packages/reporters/cli/src/CLIReporter.ts new file mode 100644 index 000000000..28325ae02 --- /dev/null +++ b/packages/reporters/cli/src/CLIReporter.ts @@ -0,0 +1,302 @@ +import type {ReporterEvent, PluginOptions} from '@atlaspack/types'; +import type {Diagnostic} from '@atlaspack/diagnostic'; +import type {Color} from 'chalk'; + +import {Reporter} from '@atlaspack/plugin'; +import { + getProgressMessage, + prettifyTime, + prettyDiagnostic, + throttle, +} from '@atlaspack/utils'; +import chalk from 'chalk'; + +import {getTerminalWidth} from './utils'; +import logLevels from './logLevels'; +import bundleReport from './bundleReport'; +import phaseReport from './phaseReport'; +import { + writeOut, + updateSpinner, + persistSpinner, + isTTY, + resetWindow, + persistMessage, +} from './render'; +import * as emoji from './emoji'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'wrap-ansi'. '/home/ubuntu/parcel/node_modules/wrap-ansi/index.js' implicitly has an 'any' type. +import wrapAnsi from 'wrap-ansi'; + +const THROTTLE_DELAY = 100; +const seenWarnings = new Set(); +const seenPhases = new Set(); +const seenPhasesGen = new Set(); + +let phaseStartTimes: Record = {}; +let pendingIncrementalBuild = false; + +let statusThrottle = throttle((message: string) => { + updateSpinner(message); +}, THROTTLE_DELAY); + +// Exported only for test +export async function _report( + event: ReporterEvent, + options: PluginOptions, +): Promise { + let logLevelFilter = logLevels[options.logLevel || 'info']; + + switch (event.type) { + case 'buildStart': { + seenWarnings.clear(); + seenPhases.clear(); + if (logLevelFilter < logLevels.info) { + break; + } + + // Clear any previous output + resetWindow(); + + if (options.serveOptions) { + persistMessage( + chalk.blue.bold( + `Server running at ${ + options.serveOptions.https ? 'https' : 'http' + }://${options.serveOptions.host ?? 'localhost'}:${ + options.serveOptions.port + }`, + ), + ); + } + + break; + } + case 'buildProgress': { + if (logLevelFilter < logLevels.info) { + break; + } + + if (pendingIncrementalBuild) { + pendingIncrementalBuild = false; + phaseStartTimes = {}; + seenPhasesGen.clear(); + seenPhases.clear(); + } + + if (!seenPhasesGen.has(event.phase)) { + phaseStartTimes[event.phase] = Date.now(); + seenPhasesGen.add(event.phase); + } + + if (!isTTY && logLevelFilter != logLevels.verbose) { + if (event.phase == 'transforming' && !seenPhases.has('transforming')) { + updateSpinner('Building...'); + } else if (event.phase == 'bundling' && !seenPhases.has('bundling')) { + updateSpinner('Bundling...'); + } else if ( + (event.phase == 'packaging' || event.phase == 'optimizing') && + !seenPhases.has('packaging') && + !seenPhases.has('optimizing') + ) { + updateSpinner('Packaging & Optimizing...'); + } + seenPhases.add(event.phase); + break; + } + + let message = getProgressMessage(event); + if (message != null) { + if (isTTY) { + statusThrottle(chalk.gray.bold(message)); + } else { + updateSpinner(message); + } + } + break; + } + case 'buildSuccess': + if (logLevelFilter < logLevels.info) { + break; + } + + phaseStartTimes['buildSuccess'] = Date.now(); + + persistSpinner( + 'buildProgress', + 'success', + chalk.green.bold(`Built in ${prettifyTime(event.buildTime)}`), + ); + + if (options.mode === 'production') { + await bundleReport( + event.bundleGraph, + options.outputFS, + options.projectRoot, + options.detailedReport?.assetsPerBundle, + ); + } else { + pendingIncrementalBuild = true; + } + + if (process.env.ATLASPACK_SHOW_PHASE_TIMES) { + phaseReport(phaseStartTimes); + } + break; + case 'buildFailure': + if (logLevelFilter < logLevels.error) { + break; + } + + resetWindow(); + + persistSpinner('buildProgress', 'error', chalk.red.bold('Build failed.')); + + await writeDiagnostic(options, event.diagnostics, 'red', true); + break; + case 'cache': + if (event.size > 500000) { + switch (event.phase) { + case 'start': + updateSpinner('Writing cache to disk'); + break; + case 'end': + persistSpinner( + 'cache', + 'success', + chalk.grey.bold(`Cache written to disk`), + ); + break; + } + } + break; + case 'log': { + if (logLevelFilter < logLevels[event.level]) { + break; + } + + switch (event.level) { + case 'success': + writeOut(chalk.green(event.message)); + break; + case 'progress': + writeOut(event.message); + break; + case 'verbose': + case 'info': + await writeDiagnostic(options, event.diagnostics, 'blue'); + break; + case 'warn': + if ( + event.diagnostics.some( + (diagnostic) => !seenWarnings.has(diagnostic.message), + ) + ) { + await writeDiagnostic(options, event.diagnostics, 'yellow', true); + for (let diagnostic of event.diagnostics) { + seenWarnings.add(diagnostic.message); + } + } + break; + case 'error': + await writeDiagnostic(options, event.diagnostics, 'red', true); + break; + default: + // @ts-expect-error - TS2339 - Property 'level' does not exist on type 'never'. + throw new Error('Unknown log level ' + event.level); + } + } + } +} + +async function writeDiagnostic( + options: PluginOptions, + diagnostics: Array, + // @ts-expect-error - TS2749 - 'Color' refers to a value, but is being used as a type here. Did you mean 'typeof Color'? + color: Color, + isError: boolean = false, +) { + let columns = getTerminalWidth().columns; + let indent = 2; + let spaceAfter = isError; + for (let diagnostic of diagnostics) { + let {message, stack, codeframe, hints, documentation} = + await prettyDiagnostic(diagnostic, options, columns - indent); + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'Color' can't be used to index type 'Chalk & ChalkFunction & { supportsColor: false | ColorSupport; Level: Level; Color: Color; ForegroundColor: ForegroundColor; BackgroundColor: BackgroundColor; Modifiers: Modifiers; stderr: Chalk & { ...; }; }'. + message = chalk[color](message); + + if (spaceAfter) { + writeOut(''); + } + + if (message) { + writeOut(wrapWithIndent(message), isError); + } + + if (stack || codeframe) { + writeOut(''); + } + + if (stack) { + writeOut(chalk.gray(wrapWithIndent(stack, indent)), isError); + } + + if (codeframe) { + writeOut(indentString(codeframe, indent), isError); + } + + if ((stack || codeframe) && (hints.length > 0 || documentation)) { + writeOut(''); + } + + // Write hints + let hintIndent = stack || codeframe ? indent : 0; + for (let hint of hints) { + writeOut( + wrapWithIndent( + `${emoji.hint} ${chalk.blue.bold(hint)}`, + hintIndent + 3, + hintIndent, + ), + ); + } + + if (documentation) { + writeOut( + wrapWithIndent( + `${emoji.docs} ${chalk.magenta.bold(documentation)}`, + hintIndent + 3, + hintIndent, + ), + ); + } + + // @ts-expect-error - TS2322 - Type 'string | true' is not assignable to type 'boolean'. + spaceAfter = stack || codeframe || hints.length > 0 || documentation; + } + + if (spaceAfter) { + writeOut(''); + } +} + +function wrapWithIndent(string: string, indent = 0, initialIndent = indent) { + let width = getTerminalWidth().columns; + return indentString( + wrapAnsi(string.trimEnd(), width - indent, {trim: false}), + indent, + initialIndent, + ); +} + +// @ts-expect-error - TS7006 - Parameter 'string' implicitly has an 'any' type. +function indentString(string, indent = 0, initialIndent = indent) { + return ( + ' '.repeat(initialIndent) + string.replace(/\n/g, '\n' + ' '.repeat(indent)) + ); +} + +export default new Reporter({ + report({event, options}) { + return _report(event, options); + }, +}) as Reporter; diff --git a/packages/reporters/cli/src/bundleReport.js b/packages/reporters/cli/src/bundleReport.js deleted file mode 100644 index 522b3c7bb..000000000 --- a/packages/reporters/cli/src/bundleReport.js +++ /dev/null @@ -1,99 +0,0 @@ -// @flow -import type {BundleGraph, FilePath, PackagedBundle} from '@atlaspack/types'; -import type {FileSystem} from '@atlaspack/fs'; - -import {generateBuildMetrics, prettifyTime} from '@atlaspack/utils'; -import filesize from 'filesize'; -import chalk from 'chalk'; -import nullthrows from 'nullthrows'; - -import * as emoji from './emoji'; -import {writeOut, table} from './render'; -import {formatFilename} from './utils'; - -const LARGE_BUNDLE_SIZE = 1024 * 1024; -const COLUMNS = [ - {align: 'left'}, // name - {align: 'right'}, // size - {align: 'right'}, // time -]; - -export default async function bundleReport( - bundleGraph: BundleGraph, - fs: FileSystem, - projectRoot: FilePath, - assetCount: number = 0, -) { - let bundleList = bundleGraph.getBundles(); - - // Get a list of bundles sorted by size - let {bundles} = - assetCount > 0 - ? await generateBuildMetrics(bundleList, fs, projectRoot) - : { - bundles: bundleList.map(b => { - return { - filePath: nullthrows(b.filePath), - size: b.stats.size, - time: b.stats.time, - assets: [], - }; - }), - }; - let rows = []; - - for (let bundle of bundles) { - // Add a row for the bundle - rows.push([ - formatFilename(bundle.filePath || '', chalk.cyan.bold), - chalk.bold(prettifySize(bundle.size, bundle.size > LARGE_BUNDLE_SIZE)), - chalk.green.bold(prettifyTime(bundle.time)), - ]); - - if (assetCount > 0) { - let largestAssets = bundle.assets.slice(0, assetCount); - for (let asset of largestAssets) { - let columns: Array = [ - asset == largestAssets[largestAssets.length - 1] ? '└── ' : '├── ', - chalk.dim(prettifySize(asset.size)), - chalk.dim(chalk.green(prettifyTime(asset.time))), - ]; - - if (asset.filePath !== '') { - columns[0] += formatFilename(asset.filePath, chalk.reset); - } else { - columns[0] += 'Code from unknown sourcefiles'; - } - - // Add a row for the asset. - rows.push(columns); - } - - if (bundle.assets.length > largestAssets.length) { - rows.push([ - '└── ' + - chalk.dim( - `+ ${bundle.assets.length - largestAssets.length} more assets`, - ), - ]); - } - - // If this isn't the last bundle, add an empty row before the next one - if (bundle !== bundles[bundles.length - 1]) { - rows.push([]); - } - } - } - - // Render table - writeOut(''); - table(COLUMNS, rows); -} - -function prettifySize(size, isLarge) { - let res = filesize(size); - if (isLarge) { - return chalk.yellow(emoji.warning + ' ' + res); - } - return chalk.magenta(res); -} diff --git a/packages/reporters/cli/src/bundleReport.ts b/packages/reporters/cli/src/bundleReport.ts new file mode 100644 index 000000000..18babde44 --- /dev/null +++ b/packages/reporters/cli/src/bundleReport.ts @@ -0,0 +1,100 @@ +import type {BundleGraph, FilePath, PackagedBundle} from '@atlaspack/types'; +import type {FileSystem} from '@atlaspack/fs'; + +import {generateBuildMetrics, prettifyTime} from '@atlaspack/utils'; +import filesize from 'filesize'; +import chalk from 'chalk'; +import nullthrows from 'nullthrows'; + +import * as emoji from './emoji'; +import {writeOut, table} from './render'; +import {formatFilename} from './utils'; + +const LARGE_BUNDLE_SIZE = 1024 * 1024; +const COLUMNS = [ + {align: 'left'}, // name + {align: 'right'}, // size + {align: 'right'}, // time +]; + +export default async function bundleReport( + bundleGraph: BundleGraph, + fs: FileSystem, + projectRoot: FilePath, + assetCount: number = 0, +) { + let bundleList = bundleGraph.getBundles(); + + // Get a list of bundles sorted by size + let {bundles} = + assetCount > 0 + ? await generateBuildMetrics(bundleList, fs, projectRoot) + : { + bundles: bundleList.map((b) => { + return { + filePath: nullthrows(b.filePath), + size: b.stats.size, + time: b.stats.time, + assets: [], + }; + }), + }; + let rows: Array> = []; + + for (let bundle of bundles) { + // Add a row for the bundle + rows.push([ + formatFilename(bundle.filePath || '', chalk.cyan.bold), + chalk.bold(prettifySize(bundle.size, bundle.size > LARGE_BUNDLE_SIZE)), + chalk.green.bold(prettifyTime(bundle.time)), + ]); + + if (assetCount > 0) { + let largestAssets = bundle.assets.slice(0, assetCount); + for (let asset of largestAssets) { + let columns: Array = [ + asset == largestAssets[largestAssets.length - 1] ? '└── ' : '├── ', + // @ts-expect-error - TS2554 - Expected 2 arguments, but got 1. + chalk.dim(prettifySize(asset.size)), + chalk.dim(chalk.green(prettifyTime(asset.time))), + ]; + + if (asset.filePath !== '') { + columns[0] += formatFilename(asset.filePath, chalk.reset); + } else { + columns[0] += 'Code from unknown sourcefiles'; + } + + // Add a row for the asset. + rows.push(columns); + } + + if (bundle.assets.length > largestAssets.length) { + rows.push([ + '└── ' + + chalk.dim( + `+ ${bundle.assets.length - largestAssets.length} more assets`, + ), + ]); + } + + // If this isn't the last bundle, add an empty row before the next one + if (bundle !== bundles[bundles.length - 1]) { + rows.push([]); + } + } + } + + // Render table + writeOut(''); + // @ts-expect-error - TS2345 - Argument of type '{ align: string; }[]' is not assignable to parameter of type 'ColumnType[]'. + table(COLUMNS, rows); +} + +function prettifySize(size: number, isLarge: undefined | boolean) { + let res = filesize(size); + if (isLarge) { + return chalk.yellow(emoji.warning + ' ' + res); + } + return chalk.magenta(res); +} diff --git a/packages/reporters/cli/src/emoji.js b/packages/reporters/cli/src/emoji.js deleted file mode 100644 index 6b05521ce..000000000 --- a/packages/reporters/cli/src/emoji.js +++ /dev/null @@ -1,29 +0,0 @@ -// @flow strict-local - -// From https://github.com/sindresorhus/is-unicode-supported/blob/8f123916d5c25a87c4f966dcc248b7ca5df2b4ca/index.js -// This package is ESM-only so it has to be vendored -function isUnicodeSupported() { - if (process.platform !== 'win32') { - return process.env.TERM !== 'linux'; // Linux console (kernel) - } - - return ( - Boolean(process.env.CI) || - Boolean(process.env.WT_SESSION) || // Windows Terminal - process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder - process.env.TERM_PROGRAM === 'vscode' || - process.env.TERM === 'xterm-256color' || - process.env.TERM === 'alacritty' - ); -} - -const supportsEmoji = isUnicodeSupported(); - -// Fallback symbols for Windows from https://en.wikipedia.org/wiki/Code_page_437 -export const progress: string = supportsEmoji ? '⏳' : '∞'; -export const success: string = supportsEmoji ? '✨' : '√'; -export const error: string = supportsEmoji ? '🚨' : '×'; -export const warning: string = supportsEmoji ? '⚠️' : '‼'; -export const info: string = supportsEmoji ? 'ℹ️' : 'ℹ'; -export const hint: string = supportsEmoji ? '💡' : 'ℹ'; -export const docs: string = supportsEmoji ? '📝' : 'ℹ'; diff --git a/packages/reporters/cli/src/emoji.ts b/packages/reporters/cli/src/emoji.ts new file mode 100644 index 000000000..e3a53bea1 --- /dev/null +++ b/packages/reporters/cli/src/emoji.ts @@ -0,0 +1,27 @@ +// From https://github.com/sindresorhus/is-unicode-supported/blob/8f123916d5c25a87c4f966dcc248b7ca5df2b4ca/index.js +// This package is ESM-only so it has to be vendored +function isUnicodeSupported() { + if (process.platform !== 'win32') { + return process.env.TERM !== 'linux'; // Linux console (kernel) + } + + return ( + Boolean(process.env.CI) || + Boolean(process.env.WT_SESSION) || // Windows Terminal + process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder + process.env.TERM_PROGRAM === 'vscode' || + process.env.TERM === 'xterm-256color' || + process.env.TERM === 'alacritty' + ); +} + +const supportsEmoji = isUnicodeSupported(); + +// Fallback symbols for Windows from https://en.wikipedia.org/wiki/Code_page_437 +export const progress: string = supportsEmoji ? '⏳' : '∞'; +export const success: string = supportsEmoji ? '✨' : '√'; +export const error: string = supportsEmoji ? '🚨' : '×'; +export const warning: string = supportsEmoji ? '⚠️' : '‼'; +export const info: string = supportsEmoji ? 'ℹ️' : 'ℹ'; +export const hint: string = supportsEmoji ? '💡' : 'ℹ'; +export const docs: string = supportsEmoji ? '📝' : 'ℹ'; diff --git a/packages/reporters/cli/src/logLevels.js b/packages/reporters/cli/src/logLevels.js deleted file mode 100644 index db1cf2557..000000000 --- a/packages/reporters/cli/src/logLevels.js +++ /dev/null @@ -1,13 +0,0 @@ -// @flow strict-local - -const logLevels = { - none: 0, - error: 1, - warn: 2, - info: 3, - progress: 3, - success: 3, - verbose: 4, -}; - -export default logLevels; diff --git a/packages/reporters/cli/src/logLevels.ts b/packages/reporters/cli/src/logLevels.ts new file mode 100644 index 000000000..b42d28fc6 --- /dev/null +++ b/packages/reporters/cli/src/logLevels.ts @@ -0,0 +1,11 @@ +const logLevels = { + none: 0, + error: 1, + warn: 2, + info: 3, + progress: 3, + success: 3, + verbose: 4, +} as const; + +export default logLevels; diff --git a/packages/reporters/cli/src/phaseReport.js b/packages/reporters/cli/src/phaseReport.js deleted file mode 100644 index 431d90699..000000000 --- a/packages/reporters/cli/src/phaseReport.js +++ /dev/null @@ -1,33 +0,0 @@ -// @flow -import {prettifyTime} from '@atlaspack/utils'; -import chalk from 'chalk'; -import {writeOut} from './render'; -import invariant from 'assert'; - -export default function phaseReport(phaseStartTimes: {[string]: number}) { - let phaseTimes = {}; - if (phaseStartTimes['transforming'] && phaseStartTimes['bundling']) { - phaseTimes['Transforming'] = - phaseStartTimes['bundling'] - phaseStartTimes['transforming']; - } - - let packagingAndOptimizing = - phaseStartTimes['packaging'] && phaseStartTimes['optimizing'] - ? Math.min(phaseStartTimes['packaging'], phaseStartTimes['optimizing']) - : phaseStartTimes['packaging'] || phaseStartTimes['optimizing']; - - if (phaseStartTimes['bundling'] && packagingAndOptimizing) { - phaseTimes['Bundling'] = - packagingAndOptimizing - phaseStartTimes['bundling']; - } - - if (packagingAndOptimizing && phaseStartTimes['buildSuccess']) { - phaseTimes['Packaging & Optimizing'] = - phaseStartTimes['buildSuccess'] - packagingAndOptimizing; - } - - for (let [phase, time] of Object.entries(phaseTimes)) { - invariant(typeof time === 'number'); - writeOut(chalk.green.bold(`${phase} finished in ${prettifyTime(time)}`)); - } -} diff --git a/packages/reporters/cli/src/phaseReport.ts b/packages/reporters/cli/src/phaseReport.ts new file mode 100644 index 000000000..e76833ed8 --- /dev/null +++ b/packages/reporters/cli/src/phaseReport.ts @@ -0,0 +1,32 @@ +import {prettifyTime} from '@atlaspack/utils'; +import chalk from 'chalk'; +import {writeOut} from './render'; +import invariant from 'assert'; + +export default function phaseReport(phaseStartTimes: {[key: string]: number}) { + let phaseTimes: Record = {}; + if (phaseStartTimes['transforming'] && phaseStartTimes['bundling']) { + phaseTimes['Transforming'] = + phaseStartTimes['bundling'] - phaseStartTimes['transforming']; + } + + let packagingAndOptimizing = + phaseStartTimes['packaging'] && phaseStartTimes['optimizing'] + ? Math.min(phaseStartTimes['packaging'], phaseStartTimes['optimizing']) + : phaseStartTimes['packaging'] || phaseStartTimes['optimizing']; + + if (phaseStartTimes['bundling'] && packagingAndOptimizing) { + phaseTimes['Bundling'] = + packagingAndOptimizing - phaseStartTimes['bundling']; + } + + if (packagingAndOptimizing && phaseStartTimes['buildSuccess']) { + phaseTimes['Packaging & Optimizing'] = + phaseStartTimes['buildSuccess'] - packagingAndOptimizing; + } + + for (let [phase, time] of Object.entries(phaseTimes)) { + invariant(typeof time === 'number'); + writeOut(chalk.green.bold(`${phase} finished in ${prettifyTime(time)}`)); + } +} diff --git a/packages/reporters/cli/src/render.js b/packages/reporters/cli/src/render.js deleted file mode 100644 index 4cdc6df0e..000000000 --- a/packages/reporters/cli/src/render.js +++ /dev/null @@ -1,149 +0,0 @@ -// @flow -import type {Writable} from 'stream'; - -import readline from 'readline'; -import ora from 'ora'; -import stringWidth from 'string-width'; - -import type {PadAlign} from './utils'; -import {pad, countLines} from './utils'; -import * as emoji from './emoji'; - -type ColumnType = {| - align: PadAlign, -|}; - -export const isTTY: any | boolean | true = - // $FlowFixMe - process.env.NODE_ENV !== 'test' && process.stdout.isTTY; - -let stdout = process.stdout; -let stderr = process.stderr; - -// Some state so we clear the output properly -let lineCount = 0; -let errorLineCount = 0; -let statusPersisted = false; - -export function _setStdio(stdoutLike: Writable, stderrLike: Writable) { - stdout = stdoutLike; - stderr = stderrLike; -} - -let spinner = ora({ - color: 'green', - stream: stdout, - discardStdin: false, -}); -let persistedMessages = []; - -export function writeOut(message: string, isError: boolean = false) { - let processedMessage = message + '\n'; - let hasSpinner = spinner.isSpinning; - - // Stop spinner so we don't duplicate it - if (hasSpinner) { - spinner.stop(); - } - - let lines = countLines(message); - if (isError) { - stderr.write(processedMessage); - errorLineCount += lines; - } else { - stdout.write(processedMessage); - lineCount += lines; - } - - // Restart the spinner - if (hasSpinner) { - spinner.start(); - } -} - -export function persistMessage(message: string) { - if (persistedMessages.includes(message)) return; - - persistedMessages.push(message); - writeOut(message); -} - -export function updateSpinner(message: string) { - // This helps the spinner play well with the tests - if (!isTTY) { - writeOut(message); - return; - } - - spinner.text = message + '\n'; - if (!spinner.isSpinning) { - spinner.start(); - } -} - -export function persistSpinner( - name: string, - status: 'success' | 'error', - message: string, -) { - spinner.stopAndPersist({ - symbol: emoji[status], - text: message, - }); - - statusPersisted = true; -} - -function clearStream(stream: Writable, lines: number) { - if (!isTTY) return; - - readline.moveCursor(stream, 0, -lines); - readline.clearScreenDown(stream); -} - -// Reset the window's state -export function resetWindow() { - if (!isTTY) return; - - // If status has been persisted we add a line - // Otherwise final states would remain in the terminal for rebuilds - if (statusPersisted) { - lineCount++; - statusPersisted = false; - } - - clearStream(stderr, errorLineCount); - errorLineCount = 0; - - clearStream(stdout, lineCount); - lineCount = 0; - - for (let m of persistedMessages) { - writeOut(m); - } -} - -export function table(columns: Array, table: Array>) { - // Measure column widths - let colWidths = []; - for (let row of table) { - let i = 0; - for (let item of row) { - colWidths[i] = Math.max(colWidths[i] || 0, stringWidth(item)); - i++; - } - } - - // Render rows - for (let row of table) { - let items = row.map((item, i) => { - // Add padding between columns unless the alignment is the opposite to the - // next column and pad to the column width. - let padding = - !columns[i + 1] || columns[i + 1].align === columns[i].align ? 4 : 0; - return pad(item, colWidths[i] + padding, columns[i].align); - }); - - writeOut(items.join('')); - } -} diff --git a/packages/reporters/cli/src/render.ts b/packages/reporters/cli/src/render.ts new file mode 100644 index 000000000..510a556a8 --- /dev/null +++ b/packages/reporters/cli/src/render.ts @@ -0,0 +1,150 @@ +import type {Writable} from 'stream'; + +import readline from 'readline'; +import ora from 'ora'; +import stringWidth from 'string-width'; + +import type {PadAlign} from './utils'; +import {pad, countLines} from './utils'; +import * as emoji from './emoji'; + +type ColumnType = { + align: PadAlign; +}; + +export const isTTY: any | boolean | true = + // $FlowFixMe + process.env.NODE_ENV !== 'test' && process.stdout.isTTY; + +let stdout = process.stdout; +let stderr = process.stderr; + +// Some state so we clear the output properly +let lineCount = 0; +let errorLineCount = 0; +let statusPersisted = false; + +export function _setStdio(stdoutLike: Writable, stderrLike: Writable) { + // @ts-expect-error - TS2322 - Type 'Writable' is not assignable to type 'WriteStream & { fd: 1; }'. + stdout = stdoutLike; + // @ts-expect-error - TS2322 - Type 'Writable' is not assignable to type 'WriteStream & { fd: 2; }'. + stderr = stderrLike; +} + +let spinner = ora({ + color: 'green', + stream: stdout, + discardStdin: false, +}); +let persistedMessages: Array = []; + +export function writeOut(message: string, isError: boolean = false) { + let processedMessage = message + '\n'; + let hasSpinner = spinner.isSpinning; + + // Stop spinner so we don't duplicate it + if (hasSpinner) { + spinner.stop(); + } + + let lines = countLines(message); + if (isError) { + stderr.write(processedMessage); + errorLineCount += lines; + } else { + stdout.write(processedMessage); + lineCount += lines; + } + + // Restart the spinner + if (hasSpinner) { + spinner.start(); + } +} + +export function persistMessage(message: string) { + if (persistedMessages.includes(message)) return; + + persistedMessages.push(message); + writeOut(message); +} + +export function updateSpinner(message: string) { + // This helps the spinner play well with the tests + if (!isTTY) { + writeOut(message); + return; + } + + spinner.text = message + '\n'; + if (!spinner.isSpinning) { + spinner.start(); + } +} + +export function persistSpinner( + name: string, + status: 'success' | 'error', + message: string, +) { + spinner.stopAndPersist({ + symbol: emoji[status], + text: message, + }); + + statusPersisted = true; +} + +function clearStream(stream: Writable, lines: number) { + if (!isTTY) return; + + readline.moveCursor(stream, 0, -lines); + readline.clearScreenDown(stream); +} + +// Reset the window's state +export function resetWindow() { + if (!isTTY) return; + + // If status has been persisted we add a line + // Otherwise final states would remain in the terminal for rebuilds + if (statusPersisted) { + lineCount++; + statusPersisted = false; + } + + clearStream(stderr, errorLineCount); + errorLineCount = 0; + + clearStream(stdout, lineCount); + lineCount = 0; + + for (let m of persistedMessages) { + writeOut(m); + } +} + +export function table(columns: Array, table: Array>) { + // Measure column widths + let colWidths: Array = []; + for (let row of table) { + let i = 0; + for (let item of row) { + colWidths[i] = Math.max(colWidths[i] || 0, stringWidth(item)); + i++; + } + } + + // Render rows + for (let row of table) { + let items = row.map((item, i) => { + // Add padding between columns unless the alignment is the opposite to the + // next column and pad to the column width. + let padding = + !columns[i + 1] || columns[i + 1].align === columns[i].align ? 4 : 0; + return pad(item, colWidths[i] + padding, columns[i].align); + }); + + writeOut(items.join('')); + } +} diff --git a/packages/reporters/cli/src/utils.js b/packages/reporters/cli/src/utils.js deleted file mode 100644 index c63360d7f..000000000 --- a/packages/reporters/cli/src/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -// @flow -import path from 'path'; -import chalk from 'chalk'; -import stringWidth from 'string-width'; -import termSize from 'term-size'; -import {stripAnsi} from '@atlaspack/utils'; - -export type PadAlign = 'left' | 'right'; -let terminalSize = termSize(); -process.stdout.on('resize', function () { - terminalSize = termSize(); -}); - -export function getTerminalWidth(): any { - return terminalSize; -} - -// Pad a string with spaces on either side -export function pad( - text: string, - length: number, - align: PadAlign = 'left', -): string { - let pad = ' '.repeat(length - stringWidth(text)); - if (align === 'right') { - return pad + text; - } - - return text + pad; -} - -export function formatFilename( - filename: string, - color: (s: string) => string = chalk.reset, -): string { - let dir = path.relative(process.cwd(), path.dirname(filename)); - return ( - chalk.dim(dir + (dir ? path.sep : '')) + color(path.basename(filename)) - ); -} - -export function countLines(message: string): number { - let {columns} = terminalSize; - - return stripAnsi(message) - .split('\n') - .reduce((p, line) => p + Math.ceil((stringWidth(line) || 1) / columns), 0); -} diff --git a/packages/reporters/cli/src/utils.ts b/packages/reporters/cli/src/utils.ts new file mode 100644 index 000000000..05a9cb368 --- /dev/null +++ b/packages/reporters/cli/src/utils.ts @@ -0,0 +1,47 @@ +import path from 'path'; +import chalk from 'chalk'; +import stringWidth from 'string-width'; +import termSize from 'term-size'; +import {stripAnsi} from '@atlaspack/utils'; + +export type PadAlign = 'left' | 'right'; +let terminalSize = termSize(); +process.stdout.on('resize', function () { + terminalSize = termSize(); +}); + +export function getTerminalWidth(): any { + return terminalSize; +} + +// Pad a string with spaces on either side +export function pad( + text: string, + length: number, + align: PadAlign = 'left', +): string { + let pad = ' '.repeat(length - stringWidth(text)); + if (align === 'right') { + return pad + text; + } + + return text + pad; +} + +export function formatFilename( + filename: string, + color: (s: string) => string = chalk.reset, +): string { + let dir = path.relative(process.cwd(), path.dirname(filename)); + return ( + chalk.dim(dir + (dir ? path.sep : '')) + color(path.basename(filename)) + ); +} + +export function countLines(message: string): number { + let {columns} = terminalSize; + + return stripAnsi(message) + .split('\n') + .reduce((p, line) => p + Math.ceil((stringWidth(line) || 1) / columns), 0); +} diff --git a/packages/reporters/cli/test/CLIReporter.test.js b/packages/reporters/cli/test/CLIReporter.test.js deleted file mode 100644 index 67ba1ab1f..000000000 --- a/packages/reporters/cli/test/CLIReporter.test.js +++ /dev/null @@ -1,257 +0,0 @@ -// @flow strict-local - -import assert from 'assert'; -import sinon from 'sinon'; -import {PassThrough} from 'stream'; -import {_report} from '../src/CLIReporter'; -import * as render from '../src/render'; -import {_setStdio} from '../src/render'; -import {inputFS, outputFS} from '@atlaspack/test-utils'; -import {NodePackageManager} from '@atlaspack/package-manager'; -import stripAnsi from 'strip-ansi'; -import * as bundleReport from '../src/bundleReport'; -import {DEFAULT_FEATURE_FLAGS} from '@atlaspack/feature-flags'; - -const EMPTY_OPTIONS = { - cacheDir: '.parcel-cache', - parcelVersion: '', - entries: [], - logLevel: 'info', - targets: [], - projectRoot: '', - distDir: 'dist', - lockFile: undefined, - shouldAutoInstall: false, - shouldBuildLazily: false, - hmrOptions: undefined, - serveOptions: false, - mode: 'development', - shouldScopeHoist: false, - shouldOptimize: false, - env: {}, - shouldDisableCache: false, - sourceMaps: false, - inputFS, - outputFS, - instanceId: 'test', - packageManager: new NodePackageManager(inputFS, '/'), - detailedReport: { - assetsPerBundle: 10, - }, - featureFlags: DEFAULT_FEATURE_FLAGS, -}; - -describe('CLIReporter', () => { - let originalStdout; - let originalStderr; - let stdoutOutput; - let stderrOutput; - - beforeEach(async () => { - // Stub these out to avoid writing noise to real stdio and to read from these - // otherwise only writable streams - originalStdout = process.stdout; - originalStderr = process.stderr; - - stdoutOutput = ''; - stderrOutput = ''; - - let mockStdout = new PassThrough(); - mockStdout.on('data', d => (stdoutOutput += stripAnsi(d.toString()))); - let mockStderr = new PassThrough(); - mockStderr.on('data', d => (stderrOutput += stripAnsi(d.toString()))); - _setStdio(mockStdout, mockStderr); - - await _report( - { - type: 'buildStart', - }, - EMPTY_OPTIONS, - ); - }); - - afterEach(() => { - _setStdio(originalStdout, originalStderr); - }); - - it('writes log, info, success, and verbose log messages to stdout', async () => { - let options = { - ...EMPTY_OPTIONS, - logLevel: 'verbose', - }; - - await _report( - { - type: 'log', - level: 'info', - diagnostics: [ - { - origin: 'test', - message: 'info', - }, - ], - }, - options, - ); - await _report({type: 'log', level: 'success', message: 'success'}, options); - await _report( - { - type: 'log', - level: 'verbose', - diagnostics: [ - { - origin: 'test', - message: 'verbose', - }, - ], - }, - options, - ); - - assert.equal(stdoutOutput, 'test: info\nsuccess\ntest: verbose\n'); - }); - - it('writes errors and warnings to stderr', async () => { - await _report( - { - type: 'log', - level: 'error', - diagnostics: [ - { - origin: 'test', - message: 'error', - }, - ], - }, - EMPTY_OPTIONS, - ); - await _report( - { - type: 'log', - level: 'warn', - diagnostics: [ - { - origin: 'test', - message: 'warn', - }, - ], - }, - EMPTY_OPTIONS, - ); - - assert.equal(stdoutOutput, '\n\n'); - assert.equal(stderrOutput, 'test: error\ntest: warn\n'); - }); - - it('prints errors nicely', async () => { - await _report( - { - type: 'log', - level: 'error', - diagnostics: [ - { - origin: 'test', - message: 'error', - }, - ], - }, - EMPTY_OPTIONS, - ); - await _report( - { - type: 'log', - level: 'warn', - diagnostics: [ - { - origin: 'test', - message: 'warn', - }, - ], - }, - EMPTY_OPTIONS, - ); - - assert.equal(stdoutOutput, '\n\n'); - assert(stderrOutput.includes('test: error\n')); - assert(stderrOutput.includes('test: warn\n')); - }); - - it('writes buildProgress messages to stdout on the default loglevel', async () => { - await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - assert.equal(stdoutOutput, 'Bundling...\n'); - }); - - it('writes buildSuccess messages to stdout on the default loglevel', async () => { - await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - assert.equal(stdoutOutput, 'Bundling...\n'); - }); - - it('writes phase timings to stdout when ATLASPACK_SHOW_PHASE_TIMES is set', async () => { - let oldPhaseTimings = process.env['ATLASPACK_SHOW_PHASE_TIMES']; - const bundleReportStub = sinon.stub(bundleReport, 'default'); - const persistSpinnerStub = sinon.stub(render, 'persistSpinner'); - - after(() => { - bundleReportStub.restore(); - persistSpinnerStub.restore(); - process.env['ATLASPACK_SHOW_PHASE_TIMES'] = oldPhaseTimings; - }); - - // emit a buildSuccess event to reset the timings and seen phases - // from the previous test - process.env['ATLASPACK_SHOW_PHASE_TIMES'] = undefined; - // $FlowFixMe - await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); - - process.env['ATLASPACK_SHOW_PHASE_TIMES'] = 'true'; - await _report( - {type: 'buildProgress', phase: 'transforming', filePath: 'foo.js'}, - EMPTY_OPTIONS, - ); - await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - await _report( - // $FlowFixMe - { - type: 'buildProgress', - phase: 'packaging', - bundle: { - displayName: 'test', - }, - }, - EMPTY_OPTIONS, - ); - // $FlowFixMe - await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); - const expected = - /Building...\nBundling...\nPackaging & Optimizing...\nTransforming finished in [0-9]ms\nBundling finished in [0-9]ms\nPackaging & Optimizing finished in [0-9]ms/; - - assert.equal(expected.test(stdoutOutput), true); - - stdoutOutput = ''; - - await _report( - {type: 'buildProgress', phase: 'transforming', filePath: 'foo.js'}, - EMPTY_OPTIONS, - ); - await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - await _report( - // $FlowFixMe - { - type: 'buildProgress', - phase: 'packaging', - bundle: { - displayName: 'test', - }, - }, - EMPTY_OPTIONS, - ); - // $FlowFixMe - await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); - - assert.equal( - expected.test(stdoutOutput), - true, - 'STDOUT output did not match', - ); - }); -}); diff --git a/packages/reporters/cli/test/CLIReporter.test.ts b/packages/reporters/cli/test/CLIReporter.test.ts new file mode 100644 index 000000000..86aae92c7 --- /dev/null +++ b/packages/reporters/cli/test/CLIReporter.test.ts @@ -0,0 +1,269 @@ +import assert from 'assert'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'sinon'. '/home/ubuntu/parcel/node_modules/sinon/lib/sinon.js' implicitly has an 'any' type. +import sinon from 'sinon'; +import {PassThrough} from 'stream'; +import {_report} from '../src/CLIReporter'; +import * as render from '../src/render'; +import {_setStdio} from '../src/render'; +import {inputFS, outputFS} from '@atlaspack/test-utils'; +import {NodePackageManager} from '@atlaspack/package-manager'; +import stripAnsi from 'strip-ansi'; +import * as bundleReport from '../src/bundleReport'; +import {DEFAULT_FEATURE_FLAGS} from '@atlaspack/feature-flags'; + +const EMPTY_OPTIONS = { + cacheDir: '.parcel-cache', + parcelVersion: '', + entries: [], + logLevel: 'info', + targets: [], + projectRoot: '', + distDir: 'dist', + lockFile: undefined, + shouldAutoInstall: false, + shouldBuildLazily: false, + hmrOptions: undefined, + serveOptions: false, + mode: 'development', + shouldScopeHoist: false, + shouldOptimize: false, + env: {}, + shouldDisableCache: false, + sourceMaps: false, + inputFS, + outputFS, + instanceId: 'test', + packageManager: new NodePackageManager(inputFS, '/'), + detailedReport: { + assetsPerBundle: 10, + }, + featureFlags: DEFAULT_FEATURE_FLAGS, +} as const; + +describe('CLIReporter', () => { + let originalStdout: any; + let originalStderr: any; + let stdoutOutput: any; + let stderrOutput: any; + + beforeEach(async () => { + // Stub these out to avoid writing noise to real stdio and to read from these + // otherwise only writable streams + originalStdout = process.stdout; + originalStderr = process.stderr; + + stdoutOutput = ''; + stderrOutput = ''; + + let mockStdout = new PassThrough(); + mockStdout.on( + 'data', + (d: any) => (stdoutOutput += stripAnsi(d.toString())), + ); + let mockStderr = new PassThrough(); + mockStderr.on( + 'data', + (d: any) => (stderrOutput += stripAnsi(d.toString())), + ); + _setStdio(mockStdout, mockStderr); + + await _report( + { + type: 'buildStart', + }, + EMPTY_OPTIONS, + ); + }); + + afterEach(() => { + _setStdio(originalStdout, originalStderr); + }); + + it('writes log, info, success, and verbose log messages to stdout', async () => { + let options = { + ...EMPTY_OPTIONS, + logLevel: 'verbose', + }; + + await _report( + { + type: 'log', + level: 'info', + diagnostics: [ + { + origin: 'test', + message: 'info', + }, + ], + }, + // @ts-expect-error - TS2345 - Argument of type '{ logLevel: string; cacheDir: ".parcel-cache"; parcelVersion: ""; entries: readonly []; targets: readonly []; projectRoot: ""; distDir: "dist"; lockFile: undefined; shouldAutoInstall: false; shouldBuildLazily: false; ... 13 more ...; featureFlags: FeatureFlags; }' is not assignable to parameter of type 'PluginOptions'. + options, + ); + // @ts-expect-error - TS2345 - Argument of type '{ logLevel: string; cacheDir: ".parcel-cache"; parcelVersion: ""; entries: readonly []; targets: readonly []; projectRoot: ""; distDir: "dist"; lockFile: undefined; shouldAutoInstall: false; shouldBuildLazily: false; ... 13 more ...; featureFlags: FeatureFlags; }' is not assignable to parameter of type 'PluginOptions'. + await _report({type: 'log', level: 'success', message: 'success'}, options); + await _report( + { + type: 'log', + level: 'verbose', + diagnostics: [ + { + origin: 'test', + message: 'verbose', + }, + ], + }, + // @ts-expect-error - TS2345 - Argument of type '{ logLevel: string; cacheDir: ".parcel-cache"; parcelVersion: ""; entries: readonly []; targets: readonly []; projectRoot: ""; distDir: "dist"; lockFile: undefined; shouldAutoInstall: false; shouldBuildLazily: false; ... 13 more ...; featureFlags: FeatureFlags; }' is not assignable to parameter of type 'PluginOptions'. + options, + ); + + assert.equal(stdoutOutput, 'test: info\nsuccess\ntest: verbose\n'); + }); + + it('writes errors and warnings to stderr', async () => { + await _report( + { + type: 'log', + level: 'error', + diagnostics: [ + { + origin: 'test', + message: 'error', + }, + ], + }, + EMPTY_OPTIONS, + ); + await _report( + { + type: 'log', + level: 'warn', + diagnostics: [ + { + origin: 'test', + message: 'warn', + }, + ], + }, + EMPTY_OPTIONS, + ); + + assert.equal(stdoutOutput, '\n\n'); + assert.equal(stderrOutput, 'test: error\ntest: warn\n'); + }); + + it('prints errors nicely', async () => { + await _report( + { + type: 'log', + level: 'error', + diagnostics: [ + { + origin: 'test', + message: 'error', + }, + ], + }, + EMPTY_OPTIONS, + ); + await _report( + { + type: 'log', + level: 'warn', + diagnostics: [ + { + origin: 'test', + message: 'warn', + }, + ], + }, + EMPTY_OPTIONS, + ); + + assert.equal(stdoutOutput, '\n\n'); + assert(stderrOutput.includes('test: error\n')); + assert(stderrOutput.includes('test: warn\n')); + }); + + it('writes buildProgress messages to stdout on the default loglevel', async () => { + await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); + assert.equal(stdoutOutput, 'Bundling...\n'); + }); + + it('writes buildSuccess messages to stdout on the default loglevel', async () => { + await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); + assert.equal(stdoutOutput, 'Bundling...\n'); + }); + + it('writes phase timings to stdout when ATLASPACK_SHOW_PHASE_TIMES is set', async () => { + let oldPhaseTimings = process.env['ATLASPACK_SHOW_PHASE_TIMES']; + const bundleReportStub = sinon.stub(bundleReport, 'default'); + const persistSpinnerStub = sinon.stub(render, 'persistSpinner'); + + after(() => { + bundleReportStub.restore(); + persistSpinnerStub.restore(); + process.env['ATLASPACK_SHOW_PHASE_TIMES'] = oldPhaseTimings; + }); + + // emit a buildSuccess event to reset the timings and seen phases + // from the previous test + process.env['ATLASPACK_SHOW_PHASE_TIMES'] = undefined; + // @ts-expect-error - TS2345 - Argument of type '{ type: "buildSuccess"; }' is not assignable to parameter of type 'ReporterEvent'. + await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); + + process.env['ATLASPACK_SHOW_PHASE_TIMES'] = 'true'; + await _report( + {type: 'buildProgress', phase: 'transforming', filePath: 'foo.js'}, + EMPTY_OPTIONS, + ); + await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); + await _report( + // $FlowFixMe + { + type: 'buildProgress', + // @ts-expect-error - TS2322 - Type '"packaging"' is not assignable to type '"optimizing"'. + phase: 'packaging', + // @ts-expect-error - TS2740 - Type '{ displayName: string; }' is missing the following properties from type 'NamedBundle': publicId, name, id, type, and 12 more. + bundle: { + displayName: 'test', + }, + }, + EMPTY_OPTIONS, + ); + // @ts-expect-error - TS2345 - Argument of type '{ type: "buildSuccess"; }' is not assignable to parameter of type 'ReporterEvent'. + await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); + const expected = + /Building...\nBundling...\nPackaging & Optimizing...\nTransforming finished in [0-9]ms\nBundling finished in [0-9]ms\nPackaging & Optimizing finished in [0-9]ms/; + + assert.equal(expected.test(stdoutOutput), true); + + stdoutOutput = ''; + + await _report( + {type: 'buildProgress', phase: 'transforming', filePath: 'foo.js'}, + EMPTY_OPTIONS, + ); + await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); + await _report( + // $FlowFixMe + { + type: 'buildProgress', + // @ts-expect-error - TS2322 - Type '"packaging"' is not assignable to type '"optimizing"'. + phase: 'packaging', + // @ts-expect-error - TS2740 - Type '{ displayName: string; }' is missing the following properties from type 'NamedBundle': publicId, name, id, type, and 12 more. + bundle: { + displayName: 'test', + }, + }, + EMPTY_OPTIONS, + ); + // @ts-expect-error - TS2345 - Argument of type '{ type: "buildSuccess"; }' is not assignable to parameter of type 'ReporterEvent'. + await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); + + assert.equal( + expected.test(stdoutOutput), + true, + 'STDOUT output did not match', + ); + }); +}); diff --git a/packages/reporters/dev-server-sw/package.json b/packages/reporters/dev-server-sw/package.json index 07eee7eb6..940ff8c82 100644 --- a/packages/reporters/dev-server-sw/package.json +++ b/packages/reporters/dev-server-sw/package.json @@ -9,7 +9,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/ServerReporter.js", - "source": "src/ServerReporter.js", + "types": "src/ServerReporter.ts", + "source": "src/ServerReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.11.0" diff --git a/packages/reporters/dev-server-sw/src/HMRServer.js b/packages/reporters/dev-server-sw/src/HMRServer.js deleted file mode 100644 index 092bd1c6b..000000000 --- a/packages/reporters/dev-server-sw/src/HMRServer.js +++ /dev/null @@ -1,187 +0,0 @@ -// @flow - -import type { - BuildSuccessEvent, - Dependency, - PluginOptions, - BundleGraph, - PackagedBundle, - Asset, -} from '@atlaspack/types'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import type {AnsiDiagnosticResult} from '@atlaspack/utils'; - -import invariant from 'assert'; -import {ansiHtml, prettyDiagnostic, PromiseQueue} from '@atlaspack/utils'; - -const HMR_ENDPOINT = '/__parcel_hmr/'; - -type HMRAsset = {| - id: string, - url: string, - type: string, - output: string, - envHash: string, - depsByBundle: {[string]: {[string]: string, ...}, ...}, -|}; - -export type HMRMessage = - | {| - type: 'update', - assets: Array, - |} - | {| - type: 'error', - diagnostics: {| - ansi: Array, - html: Array<$Rest>, - |}, - |}; - -const FS_CONCURRENCY = 64; - -export default class HMRServer { - unresolvedError: HMRMessage | null = null; - broadcast: HMRMessage => void; - - constructor(broadcast: HMRMessage => void) { - this.broadcast = broadcast; - } - - async emitError(options: PluginOptions, diagnostics: Array) { - let renderedDiagnostics = await Promise.all( - diagnostics.map(d => prettyDiagnostic(d, options)), - ); - - // store the most recent error so we can notify new connections - // and so we can broadcast when the error is resolved - this.unresolvedError = { - type: 'error', - diagnostics: { - ansi: renderedDiagnostics, - html: renderedDiagnostics.map((d, i) => { - return { - message: ansiHtml(d.message), - stack: ansiHtml(d.stack), - frames: d.frames.map(f => ({ - location: f.location, - code: ansiHtml(f.code), - })), - hints: d.hints.map(hint => ansiHtml(hint)), - documentation: diagnostics[i].documentationURL ?? '', - }; - }), - }, - }; - - this.broadcast(this.unresolvedError); - } - - async emitUpdate(event: BuildSuccessEvent) { - this.unresolvedError = null; - - let changedAssets = new Set(event.changedAssets.values()); - if (changedAssets.size === 0) return; - - let queue = new PromiseQueue({maxConcurrent: FS_CONCURRENCY}); - for (let asset of changedAssets) { - if (asset.type !== 'js' && asset.type !== 'css') { - // If all of the incoming dependencies of the asset actually resolve to a JS asset - // rather than the original, we can mark the runtimes as changed instead. URL runtimes - // have a cache busting query param added with HMR enabled which will trigger a reload. - let runtimes = new Set(); - let incomingDeps = event.bundleGraph.getIncomingDependencies(asset); - let isOnlyReferencedByRuntimes = incomingDeps.every(dep => { - let resolved = event.bundleGraph.getResolvedAsset(dep); - let isRuntime = resolved?.type === 'js' && resolved !== asset; - if (resolved && isRuntime) { - runtimes.add(resolved); - } - return isRuntime; - }); - - if (isOnlyReferencedByRuntimes) { - for (let runtime of runtimes) { - changedAssets.add(runtime); - } - - continue; - } - } - - queue.add(async () => { - let dependencies = event.bundleGraph.getDependencies(asset); - let depsByBundle = {}; - for (let bundle of event.bundleGraph.getBundlesWithAsset(asset)) { - let deps = {}; - for (let dep of dependencies) { - let resolved = event.bundleGraph.getResolvedAsset(dep, bundle); - if (resolved) { - deps[getSpecifier(dep)] = - event.bundleGraph.getAssetPublicId(resolved); - } - } - depsByBundle[bundle.id] = deps; - } - - return { - id: event.bundleGraph.getAssetPublicId(asset), - url: getSourceURL(event.bundleGraph, asset), - type: asset.type, - // No need to send the contents of non-JS assets to the client. - output: - asset.type === 'js' - ? await getHotAssetContents(event.bundleGraph, asset) - : '', - envHash: asset.env.id, - depsByBundle, - }; - }); - } - - let assets = await queue.run(); - this.broadcast({ - type: 'update', - assets: assets, - }); - } -} - -function getSpecifier(dep: Dependency): string { - if (typeof dep.meta.placeholder === 'string') { - return dep.meta.placeholder; - } - - return dep.specifier; -} - -export async function getHotAssetContents( - bundleGraph: BundleGraph, - asset: Asset, -): Promise { - let output = await asset.getCode(); - if (asset.type === 'js') { - let publicId = bundleGraph.getAssetPublicId(asset); - output = `parcelHotUpdate['${publicId}'] = function (require, module, exports) {${output}}`; - } - - let sourcemap = await asset.getMap(); - if (sourcemap) { - let sourcemapStringified = await sourcemap.stringify({ - format: 'inline', - sourceRoot: '/__parcel_source_root/', - // $FlowFixMe - fs: asset.fs, - }); - - invariant(typeof sourcemapStringified === 'string'); - output += `\n//# sourceMappingURL=${sourcemapStringified}`; - output += `\n//# sourceURL=${getSourceURL(bundleGraph, asset)}\n`; - } - - return output; -} - -function getSourceURL(bundleGraph, asset) { - return HMR_ENDPOINT + asset.id; -} diff --git a/packages/reporters/dev-server-sw/src/HMRServer.ts b/packages/reporters/dev-server-sw/src/HMRServer.ts new file mode 100644 index 000000000..3f0832075 --- /dev/null +++ b/packages/reporters/dev-server-sw/src/HMRServer.ts @@ -0,0 +1,203 @@ +// @ts-expect-error - TS2307 - Cannot find module 'flow-to-typescript-codemod' or its corresponding type declarations. +import {Flow} from 'flow-to-typescript-codemod'; + +import type { + BuildSuccessEvent, + Dependency, + PluginOptions, + BundleGraph, + PackagedBundle, + Asset, +} from '@atlaspack/types'; +import type {Diagnostic} from '@atlaspack/diagnostic'; +import type {AnsiDiagnosticResult} from '@atlaspack/utils'; + +import invariant from 'assert'; +import {ansiHtml, prettyDiagnostic, PromiseQueue} from '@atlaspack/utils'; + +const HMR_ENDPOINT = '/__parcel_hmr/'; + +type HMRAsset = { + id: string; + url: string; + type: string; + output: string; + envHash: string; + depsByBundle: { + [key: string]: { + [key: string]: string; + }; + }; +}; + +export type HMRMessage = + | { + type: 'update'; + assets: Array; + } + | { + type: 'error'; + diagnostics: { + ansi: Array; + html: Array< + Partial< + Flow.Diff< + AnsiDiagnosticResult, + { + codeframe: string; + } + > + > + >; + }; + }; + +const FS_CONCURRENCY = 64; + +export default class HMRServer { + unresolvedError: HMRMessage | null = null; + broadcast: (arg1: HMRMessage) => void; + + constructor(broadcast: (arg1: HMRMessage) => void) { + this.broadcast = broadcast; + } + + async emitError(options: PluginOptions, diagnostics: Array) { + let renderedDiagnostics = await Promise.all( + diagnostics.map((d) => prettyDiagnostic(d, options)), + ); + + // store the most recent error so we can notify new connections + // and so we can broadcast when the error is resolved + this.unresolvedError = { + type: 'error', + diagnostics: { + ansi: renderedDiagnostics, + html: renderedDiagnostics.map((d, i) => { + return { + message: ansiHtml(d.message), + stack: ansiHtml(d.stack), + frames: d.frames.map((f) => ({ + location: f.location, + code: ansiHtml(f.code), + })), + hints: d.hints.map((hint) => ansiHtml(hint)), + documentation: diagnostics[i].documentationURL ?? '', + }; + }), + }, + }; + + this.broadcast(this.unresolvedError); + } + + async emitUpdate(event: BuildSuccessEvent) { + this.unresolvedError = null; + + let changedAssets = new Set(event.changedAssets.values()); + if (changedAssets.size === 0) return; + + let queue = new PromiseQueue({maxConcurrent: FS_CONCURRENCY}); + for (let asset of changedAssets) { + if (asset.type !== 'js' && asset.type !== 'css') { + // If all of the incoming dependencies of the asset actually resolve to a JS asset + // rather than the original, we can mark the runtimes as changed instead. URL runtimes + // have a cache busting query param added with HMR enabled which will trigger a reload. + let runtimes = new Set(); + let incomingDeps = event.bundleGraph.getIncomingDependencies(asset); + let isOnlyReferencedByRuntimes = incomingDeps.every((dep) => { + let resolved = event.bundleGraph.getResolvedAsset(dep); + let isRuntime = resolved?.type === 'js' && resolved !== asset; + if (resolved && isRuntime) { + runtimes.add(resolved); + } + return isRuntime; + }); + + if (isOnlyReferencedByRuntimes) { + for (let runtime of runtimes) { + // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Asset'. + changedAssets.add(runtime); + } + + continue; + } + } + + queue.add(async () => { + let dependencies = event.bundleGraph.getDependencies(asset); + let depsByBundle: Record = {}; + for (let bundle of event.bundleGraph.getBundlesWithAsset(asset)) { + let deps: Record = {}; + for (let dep of dependencies) { + let resolved = event.bundleGraph.getResolvedAsset(dep, bundle); + if (resolved) { + deps[getSpecifier(dep)] = + event.bundleGraph.getAssetPublicId(resolved); + } + } + depsByBundle[bundle.id] = deps; + } + + return { + id: event.bundleGraph.getAssetPublicId(asset), + url: getSourceURL(event.bundleGraph, asset), + type: asset.type, + // No need to send the contents of non-JS assets to the client. + output: + asset.type === 'js' + ? await getHotAssetContents(event.bundleGraph, asset) + : '', + envHash: asset.env.id, + depsByBundle, + }; + }); + } + + let assets = await queue.run(); + this.broadcast({ + type: 'update', + // @ts-expect-error - TS2322 - Type 'unknown[]' is not assignable to type 'HMRAsset[]'. + assets: assets, + }); + } +} + +function getSpecifier(dep: Dependency): string { + if (typeof dep.meta.placeholder === 'string') { + return dep.meta.placeholder; + } + + return dep.specifier; +} + +export async function getHotAssetContents( + bundleGraph: BundleGraph, + asset: Asset, +): Promise { + let output = await asset.getCode(); + if (asset.type === 'js') { + let publicId = bundleGraph.getAssetPublicId(asset); + output = `parcelHotUpdate['${publicId}'] = function (require, module, exports) {${output}}`; + } + + let sourcemap = await asset.getMap(); + if (sourcemap) { + let sourcemapStringified = await sourcemap.stringify({ + format: 'inline', + sourceRoot: '/__parcel_source_root/', + // $FlowFixMe + fs: asset.fs, + }); + + invariant(typeof sourcemapStringified === 'string'); + output += `\n//# sourceMappingURL=${sourcemapStringified}`; + output += `\n//# sourceURL=${getSourceURL(bundleGraph, asset)}\n`; + } + + return output; +} + +function getSourceURL(bundleGraph: BundleGraph, asset: Asset) { + return HMR_ENDPOINT + asset.id; +} diff --git a/packages/reporters/dev-server-sw/src/ServerReporter.js b/packages/reporters/dev-server-sw/src/ServerReporter.js deleted file mode 100644 index 78e3af949..000000000 --- a/packages/reporters/dev-server-sw/src/ServerReporter.js +++ /dev/null @@ -1,62 +0,0 @@ -// @flow -import {Reporter} from '@atlaspack/plugin'; -import HMRServer, {getHotAssetContents} from './HMRServer'; - -let hmrServer; -let hmrAssetSourceCleanup: (() => void) | void; - -export default (new Reporter({ - async report({event, options}) { - let {hmrOptions} = options; - switch (event.type) { - case 'watchStart': { - if (hmrOptions) { - hmrServer = new HMRServer(data => - // $FlowFixMe - globalThis.ATLASPACK_SERVICE_WORKER('hmrUpdate', data), - ); - } - break; - } - case 'watchEnd': - break; - case 'buildStart': - break; - case 'buildSuccess': - { - let files: {|[string]: string|} = {}; - for (let f of await options.outputFS.readdir('/app/dist')) { - files[f] = await options.outputFS.readFile( - '/app/dist/' + f, - 'utf8', - ); - } - // $FlowFixMe - await globalThis.ATLASPACK_SERVICE_WORKER('setFS', files); - - hmrAssetSourceCleanup?.(); - // $FlowFixMe - hmrAssetSourceCleanup = globalThis.ATLASPACK_SERVICE_WORKER_REGISTER( - 'hmrAssetSource', - async id => { - let bundleGraph = event.bundleGraph; - let asset = bundleGraph.getAssetById(id); - return [ - asset.type, - await getHotAssetContents(bundleGraph, asset), - ]; - }, - ); - - if (hmrServer) { - await hmrServer?.emitUpdate(event); - } - } - break; - // We show this in the "frontend" as opposed to the iframe - // case 'buildFailure': - // await hmrServer?.emitError(options, event.diagnostics); - // break; - } - }, -}): Reporter); diff --git a/packages/reporters/dev-server-sw/src/ServerReporter.ts b/packages/reporters/dev-server-sw/src/ServerReporter.ts new file mode 100644 index 000000000..f4c052be5 --- /dev/null +++ b/packages/reporters/dev-server-sw/src/ServerReporter.ts @@ -0,0 +1,68 @@ +import {Reporter} from '@atlaspack/plugin'; +import HMRServer, {getHotAssetContents} from './HMRServer'; + +// @ts-expect-error - TS7034 - Variable 'hmrServer' implicitly has type 'any' in some locations where its type cannot be determined. +let hmrServer; +let hmrAssetSourceCleanup: (() => void) | undefined; + +export default new Reporter({ + async report({event, options}) { + let {hmrOptions} = options; + switch (event.type) { + case 'watchStart': { + if (hmrOptions) { + hmrServer = new HMRServer((data: HMRMessage) => + // $FlowFixMe + // @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. + globalThis.ATLASPACK_SERVICE_WORKER('hmrUpdate', data), + ); + } + break; + } + case 'watchEnd': + break; + case 'buildStart': + break; + case 'buildSuccess': + { + let files: { + [key: string]: string; + } = {}; + for (let f of await options.outputFS.readdir('/app/dist')) { + files[f] = await options.outputFS.readFile( + '/app/dist/' + f, + 'utf8', + ); + } + // @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. + await globalThis.ATLASPACK_SERVICE_WORKER('setFS', files); + + hmrAssetSourceCleanup?.(); + // @ts-expect-error - TS7017 - Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. + hmrAssetSourceCleanup = globalThis.ATLASPACK_SERVICE_WORKER_REGISTER( + 'hmrAssetSource', + // @ts-expect-error - TS7006 - Parameter 'id' implicitly has an 'any' type. + async (id) => { + let bundleGraph = event.bundleGraph; + let asset = bundleGraph.getAssetById(id); + return [ + asset.type, + await getHotAssetContents(bundleGraph, asset), + ]; + }, + ); + + // @ts-expect-error - TS7005 - Variable 'hmrServer' implicitly has an 'any' type. + if (hmrServer) { + // @ts-expect-error - TS7005 - Variable 'hmrServer' implicitly has an 'any' type. + await hmrServer?.emitUpdate(event); + } + } + break; + // We show this in the "frontend" as opposed to the iframe + // case 'buildFailure': + // await hmrServer?.emitError(options, event.diagnostics); + // break; + } + }, +}) as Reporter; diff --git a/packages/reporters/dev-server/package.json b/packages/reporters/dev-server/package.json index b2f89389c..3ca6ba768 100644 --- a/packages/reporters/dev-server/package.json +++ b/packages/reporters/dev-server/package.json @@ -11,7 +11,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/ServerReporter.js", - "source": "src/ServerReporter.js", + "types": "src/ServerReporter.ts", + "source": "src/ServerReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/dev-server/src/HMRServer.js b/packages/reporters/dev-server/src/HMRServer.js deleted file mode 100644 index f16b5be39..000000000 --- a/packages/reporters/dev-server/src/HMRServer.js +++ /dev/null @@ -1,301 +0,0 @@ -// @flow -import type { - Asset, - BundleGraph, - Dependency, - NamedBundle, - PackagedBundle, - PluginOptions, -} from '@atlaspack/types'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import type {AnsiDiagnosticResult} from '@atlaspack/utils'; -import type { - ServerError, - HMRServerOptions, - Request, - Response, -} from './types.js.flow'; -import {setHeaders, SOURCES_ENDPOINT} from './Server'; - -import nullthrows from 'nullthrows'; -import url from 'url'; -import mime from 'mime-types'; -import WebSocket from 'ws'; -import invariant from 'assert'; -import { - ansiHtml, - createHTTPServer, - prettyDiagnostic, - PromiseQueue, -} from '@atlaspack/utils'; - -export type HMRAsset = {| - id: string, - url: string, - type: string, - output: string, - envHash: string, - outputFormat: string, - depsByBundle: {[string]: {[string]: string, ...}, ...}, -|}; - -export type HMRMessage = - | {| - type: 'update', - assets: Array, - |} - | {| - type: 'reload', - |} - | {| - type: 'error', - diagnostics: {| - ansi: Array, - html: Array<$Rest>, - |}, - |}; - -const FS_CONCURRENCY = 64; -const HMR_ENDPOINT = '/__parcel_hmr'; -const BROADCAST_MAX_ASSETS = 10000; - -export default class HMRServer { - wss: WebSocket.Server; - unresolvedError: HMRMessage | null = null; - options: HMRServerOptions; - bundleGraph: BundleGraph | BundleGraph | null = - null; - stopServer: ?() => Promise; - - constructor(options: HMRServerOptions) { - this.options = options; - } - - async start() { - let server = this.options.devServer; - if (!server) { - let result = await createHTTPServer({ - https: this.options.https, - inputFS: this.options.inputFS, - outputFS: this.options.outputFS, - cacheDir: this.options.cacheDir, - listener: (req, res) => { - setHeaders(res); - if (!this.handle(req, res)) { - res.statusCode = 404; - res.end(); - } - }, - }); - server = result.server; - server.listen(this.options.port, this.options.host); - this.stopServer = result.stop; - } else { - this.options.addMiddleware?.((req, res) => this.handle(req, res)); - } - this.wss = new WebSocket.Server({server}); - - this.wss.on('connection', ws => { - if (this.unresolvedError) { - ws.send(JSON.stringify(this.unresolvedError)); - } - }); - - // $FlowFixMe[incompatible-exact] - this.wss.on('error', err => this.handleSocketError(err)); - } - - handle(req: Request, res: Response): boolean { - let {pathname} = url.parse(req.originalUrl || req.url); - if (pathname != null && pathname.startsWith(HMR_ENDPOINT)) { - let id = pathname.slice(HMR_ENDPOINT.length + 1); - let bundleGraph = nullthrows(this.bundleGraph); - let asset = bundleGraph.getAssetById(id); - this.getHotAssetContents(asset).then(output => { - res.setHeader('Content-Type', mime.contentType(asset.type)); - res.end(output); - }); - return true; - } - return false; - } - - async stop() { - if (this.stopServer != null) { - await this.stopServer(); - this.stopServer = null; - } - this.wss.close(); - } - - async emitError(options: PluginOptions, diagnostics: Array) { - let renderedDiagnostics = await Promise.all( - diagnostics.map(d => prettyDiagnostic(d, options)), - ); - - // store the most recent error so we can notify new connections - // and so we can broadcast when the error is resolved - this.unresolvedError = { - type: 'error', - diagnostics: { - ansi: renderedDiagnostics, - html: renderedDiagnostics.map((d, i) => { - return { - message: ansiHtml(d.message), - stack: ansiHtml(d.stack), - frames: d.frames.map(f => ({ - location: f.location, - code: ansiHtml(f.code), - })), - hints: d.hints.map(hint => ansiHtml(hint)), - documentation: diagnostics[i].documentationURL ?? '', - }; - }), - }, - }; - - this.broadcast(this.unresolvedError); - } - - async emitUpdate(event: { - +bundleGraph: BundleGraph | BundleGraph, - +changedAssets: Map, - ... - }) { - this.unresolvedError = null; - this.bundleGraph = event.bundleGraph; - - let changedAssets = new Set(event.changedAssets.values()); - if (changedAssets.size === 0) return; - - let queue = new PromiseQueue({maxConcurrent: FS_CONCURRENCY}); - for (let asset of changedAssets) { - if (asset.type !== 'js' && asset.type !== 'css') { - // If all of the incoming dependencies of the asset actually resolve to a JS asset - // rather than the original, we can mark the runtimes as changed instead. URL runtimes - // have a cache busting query param added with HMR enabled which will trigger a reload. - let runtimes = new Set(); - let incomingDeps = event.bundleGraph.getIncomingDependencies(asset); - let isOnlyReferencedByRuntimes = incomingDeps.every(dep => { - let resolved = event.bundleGraph.getResolvedAsset(dep); - let isRuntime = resolved?.type === 'js' && resolved !== asset; - if (resolved && isRuntime) { - runtimes.add(resolved); - } - return isRuntime; - }); - - if (isOnlyReferencedByRuntimes) { - for (let runtime of runtimes) { - changedAssets.add(runtime); - } - - continue; - } - } - - queue.add(async () => { - let dependencies = event.bundleGraph.getDependencies(asset); - let depsByBundle = {}; - for (let bundle of event.bundleGraph.getBundlesWithAsset(asset)) { - let deps = {}; - for (let dep of dependencies) { - let resolved = event.bundleGraph.getResolvedAsset(dep, bundle); - if (resolved) { - deps[getSpecifier(dep)] = - event.bundleGraph.getAssetPublicId(resolved); - } - } - depsByBundle[bundle.id] = deps; - } - - return { - id: event.bundleGraph.getAssetPublicId(asset), - url: this.getSourceURL(asset), - type: asset.type, - // No need to send the contents of non-JS assets to the client. - output: - asset.type === 'js' ? await this.getHotAssetContents(asset) : '', - envHash: asset.env.id, - outputFormat: asset.env.outputFormat, - depsByBundle, - }; - }); - } - - let assets = await queue.run(); - - if (assets.length >= BROADCAST_MAX_ASSETS) { - // Too many assets to send via an update without errors, just reload instead - this.broadcast({type: 'reload'}); - } else { - this.broadcast({ - type: 'update', - assets, - }); - } - } - - async getHotAssetContents(asset: Asset): Promise { - let output = await asset.getCode(); - let bundleGraph = nullthrows(this.bundleGraph); - if (asset.type === 'js') { - let publicId = bundleGraph.getAssetPublicId(asset); - output = `parcelHotUpdate['${publicId}'] = function (require, module, exports) {${output}}`; - } - - let sourcemap = await asset.getMap(); - if (sourcemap) { - let sourcemapStringified = await sourcemap.stringify({ - format: 'inline', - sourceRoot: SOURCES_ENDPOINT + '/', - // $FlowFixMe - fs: asset.fs, - }); - - invariant(typeof sourcemapStringified === 'string'); - output += `\n//# sourceMappingURL=${sourcemapStringified}`; - output += `\n//# sourceURL=${encodeURI(this.getSourceURL(asset))}\n`; - } - - return output; - } - - getSourceURL(asset: Asset): string { - let origin = ''; - if (!this.options.devServer) { - origin = `http://${this.options.host || 'localhost'}:${ - this.options.port - }`; - } - return origin + HMR_ENDPOINT + '/' + asset.id; - } - - handleSocketError(err: ServerError) { - if (err.code === 'ECONNRESET') { - // This gets triggered on page refresh, ignore this - return; - } - - this.options.logger.warn({ - origin: '@atlaspack/reporter-dev-server', - message: `[${err.code}]: ${err.message}`, - stack: err.stack, - }); - } - - broadcast(msg: HMRMessage) { - const json = JSON.stringify(msg); - for (let ws of this.wss.clients) { - ws.send(json); - } - } -} - -function getSpecifier(dep: Dependency): string { - if (typeof dep.meta.placeholder === 'string') { - return dep.meta.placeholder; - } - - return dep.specifier; -} diff --git a/packages/reporters/dev-server/src/HMRServer.ts b/packages/reporters/dev-server/src/HMRServer.ts new file mode 100644 index 000000000..d1d5541cc --- /dev/null +++ b/packages/reporters/dev-server/src/HMRServer.ts @@ -0,0 +1,326 @@ +// @ts-expect-error - TS2307 - Cannot find module 'flow-to-typescript-codemod' or its corresponding type declarations. +import {Flow} from 'flow-to-typescript-codemod'; +import type { + Asset, + BundleGraph, + Dependency, + NamedBundle, + PackagedBundle, + PluginOptions, +} from '@atlaspack/types'; +import type {Diagnostic} from '@atlaspack/diagnostic'; +import type {AnsiDiagnosticResult} from '@atlaspack/utils'; +import type { + ServerError, + HMRServerOptions, + Request, + Response, + // @ts-expect-error - TS2307 - Cannot find module './types.js.flow' or its corresponding type declarations. +} from './types.js.flow'; +import {setHeaders, SOURCES_ENDPOINT} from './Server'; + +import nullthrows from 'nullthrows'; +import url from 'url'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'mime-types'. '/home/ubuntu/parcel/node_modules/mime-types/index.js' implicitly has an 'any' type. +import mime from 'mime-types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'ws'. '/home/ubuntu/parcel/node_modules/ws/index.js' implicitly has an 'any' type. +import WebSocket from 'ws'; +import invariant from 'assert'; +import { + ansiHtml, + createHTTPServer, + prettyDiagnostic, + PromiseQueue, +} from '@atlaspack/utils'; + +export type HMRAsset = { + id: string; + url: string; + type: string; + output: string; + envHash: string; + outputFormat: string; + depsByBundle: { + [key: string]: { + [key: string]: string; + }; + }; +}; + +export type HMRMessage = + | { + type: 'update'; + assets: Array; + } + | { + type: 'reload'; + } + | { + type: 'error'; + diagnostics: { + ansi: Array; + html: Array< + Partial< + Flow.Diff< + AnsiDiagnosticResult, + { + codeframe: string; + } + > + > + >; + }; + }; + +const FS_CONCURRENCY = 64; +const HMR_ENDPOINT = '/__parcel_hmr'; +const BROADCAST_MAX_ASSETS = 10000; + +export default class HMRServer { + wss: WebSocket.Server; + unresolvedError: HMRMessage | null = null; + options: HMRServerOptions; + bundleGraph: BundleGraph | BundleGraph | null = + null; + // @ts-expect-error - TS2564 - Property 'stopServer' has no initializer and is not definitely assigned in the constructor. + stopServer: () => Promise | null | undefined; + + constructor(options: HMRServerOptions) { + this.options = options; + } + + async start() { + let server = this.options.devServer; + if (!server) { + let result = await createHTTPServer({ + https: this.options.https, + inputFS: this.options.inputFS, + outputFS: this.options.outputFS, + cacheDir: this.options.cacheDir, + listener: (req, res) => { + setHeaders(res); + if (!this.handle(req, res)) { + res.statusCode = 404; + res.end(); + } + }, + }); + server = result.server; + server.listen(this.options.port, this.options.host); + this.stopServer = result.stop; + } else { + this.options.addMiddleware?.((req: Request, res: Response) => + this.handle(req, res), + ); + } + this.wss = new WebSocket.Server({server}); + + // @ts-expect-error - TS7006 - Parameter 'ws' implicitly has an 'any' type. + this.wss.on('connection', (ws) => { + if (this.unresolvedError) { + ws.send(JSON.stringify(this.unresolvedError)); + } + }); + + // @ts-expect-error - TS7006 - Parameter 'err' implicitly has an 'any' type. + this.wss.on('error', (err) => this.handleSocketError(err)); + } + + handle(req: Request, res: Response): boolean { + let {pathname} = url.parse(req.originalUrl || req.url); + if (pathname != null && pathname.startsWith(HMR_ENDPOINT)) { + let id = pathname.slice(HMR_ENDPOINT.length + 1); + let bundleGraph = nullthrows(this.bundleGraph); + let asset = bundleGraph.getAssetById(id); + this.getHotAssetContents(asset).then((output) => { + res.setHeader('Content-Type', mime.contentType(asset.type)); + res.end(output); + }); + return true; + } + return false; + } + + async stop() { + if (this.stopServer != null) { + await this.stopServer(); + // @ts-expect-error - TS2322 - Type 'null' is not assignable to type '() => Promise | null | undefined'. + this.stopServer = null; + } + this.wss.close(); + } + + async emitError(options: PluginOptions, diagnostics: Array) { + let renderedDiagnostics = await Promise.all( + diagnostics.map((d) => prettyDiagnostic(d, options)), + ); + + // store the most recent error so we can notify new connections + // and so we can broadcast when the error is resolved + this.unresolvedError = { + type: 'error', + diagnostics: { + ansi: renderedDiagnostics, + html: renderedDiagnostics.map((d, i) => { + return { + message: ansiHtml(d.message), + stack: ansiHtml(d.stack), + frames: d.frames.map((f) => ({ + location: f.location, + code: ansiHtml(f.code), + })), + hints: d.hints.map((hint) => ansiHtml(hint)), + documentation: diagnostics[i].documentationURL ?? '', + }; + }), + }, + }; + + this.broadcast(this.unresolvedError); + } + + async emitUpdate(event: { + readonly bundleGraph: + | BundleGraph + | BundleGraph; + readonly changedAssets: Map; + }) { + this.unresolvedError = null; + this.bundleGraph = event.bundleGraph; + + let changedAssets = new Set(event.changedAssets.values()); + if (changedAssets.size === 0) return; + + let queue = new PromiseQueue({maxConcurrent: FS_CONCURRENCY}); + for (let asset of changedAssets) { + if (asset.type !== 'js' && asset.type !== 'css') { + // If all of the incoming dependencies of the asset actually resolve to a JS asset + // rather than the original, we can mark the runtimes as changed instead. URL runtimes + // have a cache busting query param added with HMR enabled which will trigger a reload. + let runtimes = new Set(); + let incomingDeps = event.bundleGraph.getIncomingDependencies(asset); + let isOnlyReferencedByRuntimes = incomingDeps.every((dep) => { + let resolved = event.bundleGraph.getResolvedAsset(dep); + let isRuntime = resolved?.type === 'js' && resolved !== asset; + if (resolved && isRuntime) { + runtimes.add(resolved); + } + return isRuntime; + }); + + if (isOnlyReferencedByRuntimes) { + for (let runtime of runtimes) { + // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Asset'. + changedAssets.add(runtime); + } + + continue; + } + } + + queue.add(async () => { + let dependencies = event.bundleGraph.getDependencies(asset); + let depsByBundle: Record = {}; + for (let bundle of event.bundleGraph.getBundlesWithAsset(asset)) { + let deps: Record = {}; + for (let dep of dependencies) { + let resolved = event.bundleGraph.getResolvedAsset(dep, bundle); + if (resolved) { + deps[getSpecifier(dep)] = + event.bundleGraph.getAssetPublicId(resolved); + } + } + depsByBundle[bundle.id] = deps; + } + + return { + id: event.bundleGraph.getAssetPublicId(asset), + url: this.getSourceURL(asset), + type: asset.type, + // No need to send the contents of non-JS assets to the client. + output: + asset.type === 'js' ? await this.getHotAssetContents(asset) : '', + envHash: asset.env.id, + outputFormat: asset.env.outputFormat, + depsByBundle, + }; + }); + } + + let assets = await queue.run(); + + if (assets.length >= BROADCAST_MAX_ASSETS) { + // Too many assets to send via an update without errors, just reload instead + this.broadcast({type: 'reload'}); + } else { + this.broadcast({ + type: 'update', + // @ts-expect-error - TS2322 - Type 'unknown[]' is not assignable to type 'HMRAsset[]'. + assets, + }); + } + } + + async getHotAssetContents(asset: Asset): Promise { + let output = await asset.getCode(); + let bundleGraph = nullthrows(this.bundleGraph); + if (asset.type === 'js') { + let publicId = bundleGraph.getAssetPublicId(asset); + output = `parcelHotUpdate['${publicId}'] = function (require, module, exports) {${output}}`; + } + + let sourcemap = await asset.getMap(); + if (sourcemap) { + let sourcemapStringified = await sourcemap.stringify({ + format: 'inline', + sourceRoot: SOURCES_ENDPOINT + '/', + // $FlowFixMe + fs: asset.fs, + }); + + invariant(typeof sourcemapStringified === 'string'); + output += `\n//# sourceMappingURL=${sourcemapStringified}`; + output += `\n//# sourceURL=${encodeURI(this.getSourceURL(asset))}\n`; + } + + return output; + } + + getSourceURL(asset: Asset): string { + let origin = ''; + if (!this.options.devServer) { + origin = `http://${this.options.host || 'localhost'}:${ + this.options.port + }`; + } + return origin + HMR_ENDPOINT + '/' + asset.id; + } + + handleSocketError(err: ServerError) { + if (err.code === 'ECONNRESET') { + // This gets triggered on page refresh, ignore this + return; + } + + this.options.logger.warn({ + origin: '@atlaspack/reporter-dev-server', + message: `[${err.code}]: ${err.message}`, + stack: err.stack, + }); + } + + broadcast(msg: HMRMessage) { + const json = JSON.stringify(msg); + for (let ws of this.wss.clients) { + ws.send(json); + } + } +} + +function getSpecifier(dep: Dependency): string { + if (typeof dep.meta.placeholder === 'string') { + return dep.meta.placeholder; + } + + return dep.specifier; +} diff --git a/packages/reporters/dev-server/src/Server.js b/packages/reporters/dev-server/src/Server.js deleted file mode 100644 index 857c92c72..000000000 --- a/packages/reporters/dev-server/src/Server.js +++ /dev/null @@ -1,544 +0,0 @@ -// @flow - -import type {DevServerOptions, Request, Response} from './types.js.flow'; -import type { - BuildSuccessEvent, - BundleGraph, - FilePath, - PluginOptions, - PackagedBundle, -} from '@atlaspack/types'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import type {FileSystem} from '@atlaspack/fs'; -import type {HTTPServer, FormattedCodeFrame} from '@atlaspack/utils'; - -import invariant from 'assert'; -import path from 'path'; -import url from 'url'; -import { - ansiHtml, - createHTTPServer, - resolveConfig, - readConfig, - prettyDiagnostic, - relativePath, -} from '@atlaspack/utils'; -import serverErrors from './serverErrors'; -import fs from 'fs'; -import ejs from 'ejs'; -import connect from 'connect'; -import serveHandler from 'serve-handler'; -import {createProxyMiddleware} from 'http-proxy-middleware'; -import {URL, URLSearchParams} from 'url'; -import launchEditor from 'launch-editor'; -import fresh from 'fresh'; - -export function setHeaders(res: Response) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader( - 'Access-Control-Allow-Methods', - 'GET, HEAD, PUT, PATCH, POST, DELETE', - ); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, Content-Type', - ); - res.setHeader('Cache-Control', 'max-age=0, must-revalidate'); -} - -const SLASH_REGEX = /\//g; - -export const SOURCES_ENDPOINT = '/__parcel_source_root'; -const EDITOR_ENDPOINT = '/__parcel_launch_editor'; -const TEMPLATE_404 = fs.readFileSync( - path.join(__dirname, 'templates/404.html'), - 'utf8', -); - -const TEMPLATE_500 = fs.readFileSync( - path.join(__dirname, 'templates/500.html'), - 'utf8', -); -type NextFunction = (req: Request, res: Response, next?: (any) => any) => any; - -export default class Server { - pending: boolean; - pendingRequests: Array<[Request, Response]>; - middleware: Array<(req: Request, res: Response) => boolean>; - options: DevServerOptions; - rootPath: string; - bundleGraph: BundleGraph | null; - requestBundle: ?(bundle: PackagedBundle) => Promise; - errors: Array<{| - message: string, - stack: ?string, - frames: Array, - hints: Array, - documentation: string, - |}> | null; - stopServer: ?() => Promise; - - constructor(options: DevServerOptions) { - this.options = options; - try { - this.rootPath = new URL(options.publicUrl).pathname; - } catch (e) { - this.rootPath = options.publicUrl; - } - this.pending = true; - this.pendingRequests = []; - this.middleware = []; - this.bundleGraph = null; - this.requestBundle = null; - this.errors = null; - } - - buildStart() { - this.pending = true; - } - - buildSuccess( - bundleGraph: BundleGraph, - requestBundle: (bundle: PackagedBundle) => Promise, - ) { - this.bundleGraph = bundleGraph; - this.requestBundle = requestBundle; - this.errors = null; - this.pending = false; - - if (this.pendingRequests.length > 0) { - let pendingRequests = this.pendingRequests; - this.pendingRequests = []; - for (let [req, res] of pendingRequests) { - this.respond(req, res); - } - } - } - - async buildError(options: PluginOptions, diagnostics: Array) { - this.pending = false; - this.errors = await Promise.all( - diagnostics.map(async d => { - let ansiDiagnostic = await prettyDiagnostic(d, options); - - return { - message: ansiHtml(ansiDiagnostic.message), - stack: ansiDiagnostic.stack ? ansiHtml(ansiDiagnostic.stack) : null, - frames: ansiDiagnostic.frames.map(f => ({ - location: f.location, - code: ansiHtml(f.code), - })), - hints: ansiDiagnostic.hints.map(hint => ansiHtml(hint)), - documentation: d.documentationURL ?? '', - }; - }), - ); - } - - respond(req: Request, res: Response): mixed { - if (this.middleware.some(handler => handler(req, res))) return; - let {pathname, search} = url.parse(req.originalUrl || req.url); - if (pathname == null) { - pathname = '/'; - } - - if (pathname.startsWith(EDITOR_ENDPOINT) && search) { - let query = new URLSearchParams(search); - let file = query.get('file'); - if (file) { - // File location might start with /__parcel_source_root if it came from a source map. - if (file.startsWith(SOURCES_ENDPOINT)) { - file = file.slice(SOURCES_ENDPOINT.length + 1); - } - launchEditor(file); - } - res.end(); - } else if (this.errors) { - return this.send500(req, res); - } else if (path.extname(pathname) === '') { - // If the URL doesn't start with the public path, or the URL doesn't - // have a file extension, send the main HTML bundle. - return this.sendIndex(req, res); - } else if (pathname.startsWith(SOURCES_ENDPOINT)) { - req.url = pathname.slice(SOURCES_ENDPOINT.length); - return this.serve( - this.options.inputFS, - this.options.projectRoot, - req, - res, - () => this.send404(req, res), - ); - } else if (pathname.startsWith(this.rootPath)) { - // Otherwise, serve the file from the dist folder - req.url = - this.rootPath === '/' ? pathname : pathname.slice(this.rootPath.length); - if (req.url[0] !== '/') { - req.url = '/' + req.url; - } - return this.serveBundle(req, res, () => this.sendIndex(req, res)); - } else { - return this.send404(req, res); - } - } - - sendIndex(req: Request, res: Response) { - if (this.bundleGraph) { - // If the main asset is an HTML file, serve it - let htmlBundleFilePaths = this.bundleGraph - .getBundles() - .filter(bundle => path.posix.extname(bundle.name) === '.html') - .map(bundle => { - return `/${relativePath( - this.options.distDir, - bundle.filePath, - false, - )}`; - }); - - let indexFilePath = null; - let {pathname: reqURL} = url.parse(req.originalUrl || req.url); - - if (!reqURL) { - reqURL = '/'; - } - - if (htmlBundleFilePaths.length === 1) { - indexFilePath = htmlBundleFilePaths[0]; - } else { - let bestMatch = null; - for (let bundle of htmlBundleFilePaths) { - let bundleDir = path.posix.dirname(bundle); - let bundleDirSubdir = bundleDir === '/' ? bundleDir : bundleDir + '/'; - let withoutExtension = path.posix.basename( - bundle, - path.posix.extname(bundle), - ); - let isIndex = withoutExtension === 'index'; - - let matchesIsIndex = null; - if ( - isIndex && - (reqURL.startsWith(bundleDirSubdir) || reqURL === bundleDir) - ) { - // bundle is /bar/index.html and (/bar or something inside of /bar/** was requested was requested) - matchesIsIndex = true; - } else if (reqURL == path.posix.join(bundleDir, withoutExtension)) { - // bundle is /bar/foo.html and /bar/foo was requested - matchesIsIndex = false; - } - if (matchesIsIndex != null) { - let depth = bundle.match(SLASH_REGEX)?.length ?? 0; - if ( - bestMatch == null || - // This one is more specific (deeper) - bestMatch.depth < depth || - // This one is just as deep, but the bundle name matches and not just index.html - (bestMatch.depth === depth && bestMatch.isIndex) - ) { - bestMatch = {bundle, depth, isIndex: matchesIsIndex}; - } - } - } - indexFilePath = bestMatch?.['bundle'] ?? htmlBundleFilePaths[0]; - } - - if (indexFilePath) { - req.url = indexFilePath; - this.serveBundle(req, res, () => this.send404(req, res)); - } else { - this.send404(req, res); - } - } else { - this.send404(req, res); - } - } - - async serveBundle( - req: Request, - res: Response, - next: NextFunction, - ): Promise { - let bundleGraph = this.bundleGraph; - if (bundleGraph) { - let {pathname} = url.parse(req.url); - if (!pathname) { - this.send500(req, res); - return; - } - - let requestedPath = path.normalize(pathname.slice(1)); - let bundle = bundleGraph - .getBundles() - .find( - b => - path.relative(this.options.distDir, b.filePath) === requestedPath, - ); - if (!bundle) { - this.serveDist(req, res, next); - return; - } - - invariant(this.requestBundle != null); - try { - await this.requestBundle(bundle); - } catch (err) { - this.send500(req, res); - return; - } - - this.serveDist(req, res, next); - } else { - this.send404(req, res); - } - } - - serveDist( - req: Request, - res: Response, - next: NextFunction, - ): Promise | Promise { - return this.serve( - this.options.outputFS, - this.options.distDir, - req, - res, - next, - ); - } - - async serve( - fs: FileSystem, - root: FilePath, - req: Request, - res: Response, - next: NextFunction, - ): Promise { - if (req.method !== 'GET' && req.method !== 'HEAD') { - // method not allowed - res.statusCode = 405; - res.setHeader('Allow', 'GET, HEAD'); - res.setHeader('Content-Length', '0'); - res.end(); - return; - } - - try { - var filePath = url.parse(req.url).pathname || ''; - filePath = decodeURIComponent(filePath); - } catch (err) { - return this.sendError(res, 400); - } - - filePath = path.normalize('.' + path.sep + filePath); - - // malicious path - if (filePath.includes(path.sep + '..' + path.sep)) { - return this.sendError(res, 403); - } - - // join / normalize from the root dir - if (!path.isAbsolute(filePath)) { - filePath = path.normalize(path.join(root, filePath)); - } - - try { - var stat = await fs.stat(filePath); - } catch (err) { - if (err.code === 'ENOENT') { - return next(req, res); - } - - return this.sendError(res, 500); - } - - // Fall back to next handler if not a file - if (!stat || !stat.isFile()) { - return next(req, res); - } - - if (fresh(req.headers, {'last-modified': stat.mtime.toUTCString()})) { - res.statusCode = 304; - res.end(); - return; - } - - return serveHandler( - req, - res, - { - public: root, - cleanUrls: false, - }, - { - lstat: path => fs.stat(path), - realpath: path => fs.realpath(path), - createReadStream: (path, options) => fs.createReadStream(path, options), - readdir: path => fs.readdir(path), - }, - ); - } - - sendError(res: Response, statusCode: number) { - res.statusCode = statusCode; - res.end(); - } - - send404(req: Request, res: Response) { - res.statusCode = 404; - res.end(TEMPLATE_404); - } - - send500(req: Request, res: Response): void | Response { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.writeHead(500); - - if (this.errors) { - return res.end( - ejs.render(TEMPLATE_500, { - errors: this.errors, - hmrOptions: this.options.hmrOptions, - }), - ); - } - } - - logAccessIfVerbose(req: Request) { - this.options.logger.verbose({ - message: `Request: ${req.headers.host}${req.originalUrl || req.url}`, - }); - } - - /** - * Load proxy table from package.json and apply them. - */ - async applyProxyTable(app: any): Promise { - // avoid skipping project root - const fileInRoot: string = path.join(this.options.projectRoot, 'index'); - - const configFilePath = await resolveConfig( - this.options.inputFS, - fileInRoot, - [ - '.proxyrc.cts', - '.proxyrc.mts', - '.proxyrc.ts', - '.proxyrc.cjs', - '.proxyrc.mjs', - '.proxyrc.js', - '.proxyrc', - '.proxyrc.json', - ], - this.options.projectRoot, - ); - - if (!configFilePath) { - return this; - } - - const filename = path.basename(configFilePath); - - if (filename === '.proxyrc' || filename === '.proxyrc.json') { - let conf = await readConfig(this.options.inputFS, configFilePath); - if (!conf) { - return this; - } - let cfg = conf.config; - if (typeof cfg !== 'object') { - this.options.logger.warn({ - message: - "Proxy table in '.proxyrc' should be of object type. Skipping...", - }); - return this; - } - for (const [context, options] of Object.entries(cfg)) { - // each key is interpreted as context, and value as middleware options - app.use(createProxyMiddleware(context, options)); - } - } else { - let cfg = await this.options.packageManager.require( - configFilePath, - fileInRoot, - ); - if ( - // $FlowFixMe - Object.prototype.toString.call(cfg) === '[object Module]' - ) { - cfg = cfg.default; - } - - if (typeof cfg !== 'function') { - this.options.logger.warn({ - message: `Proxy configuration file '${filename}' should export a function. Skipping...`, - }); - return this; - } - cfg(app); - } - - return this; - } - - async start(): Promise { - const finalHandler = (req: Request, res: Response) => { - this.logAccessIfVerbose(req); - - // Wait for the parcelInstance to finish bundling if needed - if (this.pending) { - this.pendingRequests.push([req, res]); - } else { - this.respond(req, res); - } - }; - - const app = connect(); - app.use((req, res, next) => { - setHeaders(res); - next(); - }); - - app.use((req, res, next) => { - if (req.url === '/__parcel_healthcheck') { - res.statusCode = 200; - res.write(`${Date.now()}`); - res.end(); - } else { - next(); - } - }); - - await this.applyProxyTable(app); - app.use(finalHandler); - - let {server, stop} = await createHTTPServer({ - cacheDir: this.options.cacheDir, - https: this.options.https, - inputFS: this.options.inputFS, - listener: app, - outputFS: this.options.outputFS, - host: this.options.host, - }); - this.stopServer = stop; - - server.listen(this.options.port, this.options.host); - return new Promise((resolve, reject) => { - server.once('error', err => { - this.options.logger.error( - ({ - message: serverErrors(err, this.options.port), - }: Diagnostic), - ); - reject(err); - }); - - server.once('listening', () => { - resolve(server); - }); - }); - } - - async stop(): Promise { - invariant(this.stopServer != null); - await this.stopServer(); - this.stopServer = null; - } -} diff --git a/packages/reporters/dev-server/src/Server.ts b/packages/reporters/dev-server/src/Server.ts new file mode 100644 index 000000000..5f2f39618 --- /dev/null +++ b/packages/reporters/dev-server/src/Server.ts @@ -0,0 +1,570 @@ +// @ts-expect-error - TS2307 - Cannot find module './types.js.flow' or its corresponding type declarations. +import type {DevServerOptions, Request, Response} from './types.js.flow'; +import type { + BuildSuccessEvent, + BundleGraph, + FilePath, + PluginOptions, + PackagedBundle, +} from '@atlaspack/types'; +import type {Diagnostic} from '@atlaspack/diagnostic'; +import type {FileSystem} from '@atlaspack/fs'; +import type {HTTPServer, FormattedCodeFrame} from '@atlaspack/utils'; + +import invariant from 'assert'; +import path from 'path'; +import url from 'url'; +import { + ansiHtml, + createHTTPServer, + resolveConfig, + readConfig, + prettyDiagnostic, + relativePath, +} from '@atlaspack/utils'; +import serverErrors from './serverErrors'; +import fs from 'fs'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'ejs'. '/home/ubuntu/parcel/node_modules/ejs/lib/ejs.js' implicitly has an 'any' type. +import ejs from 'ejs'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'connect'. '/home/ubuntu/parcel/node_modules/connect/index.js' implicitly has an 'any' type. +import connect from 'connect'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'serve-handler'. '/home/ubuntu/parcel/node_modules/serve-handler/src/index.js' implicitly has an 'any' type. +import serveHandler from 'serve-handler'; +import {createProxyMiddleware} from 'http-proxy-middleware'; +import {URL, URLSearchParams} from 'url'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'launch-editor'. '/home/ubuntu/parcel/node_modules/launch-editor/index.js' implicitly has an 'any' type. +import launchEditor from 'launch-editor'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'fresh'. '/home/ubuntu/parcel/node_modules/fresh/index.js' implicitly has an 'any' type. +import fresh from 'fresh'; + +export function setHeaders(res: Response) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET, HEAD, PUT, PATCH, POST, DELETE', + ); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Content-Type', + ); + res.setHeader('Cache-Control', 'max-age=0, must-revalidate'); +} + +const SLASH_REGEX = /\//g; + +export const SOURCES_ENDPOINT = '/__parcel_source_root'; +const EDITOR_ENDPOINT = '/__parcel_launch_editor'; +const TEMPLATE_404 = fs.readFileSync( + path.join(__dirname, 'templates/404.html'), + 'utf8', +); + +const TEMPLATE_500 = fs.readFileSync( + path.join(__dirname, 'templates/500.html'), + 'utf8', +); +type NextFunction = ( + req: Request, + res: Response, + next?: (arg1?: any) => any, +) => any; + +export default class Server { + pending: boolean; + pendingRequests: Array<[Request, Response]>; + middleware: Array<(req: Request, res: Response) => boolean>; + options: DevServerOptions; + rootPath: string; + bundleGraph: BundleGraph | null; + requestBundle: ( + bundle: PackagedBundle, + ) => Promise | null | undefined; + errors: Array<{ + message: string; + stack: string | null | undefined; + frames: Array; + hints: Array; + documentation: string; + }> | null; + // @ts-expect-error - TS2564 - Property 'stopServer' has no initializer and is not definitely assigned in the constructor. + stopServer: () => Promise | null | undefined; + + constructor(options: DevServerOptions) { + this.options = options; + try { + this.rootPath = new URL(options.publicUrl).pathname; + } catch (e: any) { + this.rootPath = options.publicUrl; + } + this.pending = true; + this.pendingRequests = []; + this.middleware = []; + this.bundleGraph = null; + // @ts-expect-error - TS2322 - Type 'null' is not assignable to type '(bundle: PackagedBundle) => Promise | null | undefined'. + this.requestBundle = null; + this.errors = null; + } + + buildStart() { + this.pending = true; + } + + buildSuccess( + bundleGraph: BundleGraph, + requestBundle: (bundle: PackagedBundle) => Promise, + ) { + this.bundleGraph = bundleGraph; + this.requestBundle = requestBundle; + this.errors = null; + this.pending = false; + + if (this.pendingRequests.length > 0) { + let pendingRequests = this.pendingRequests; + this.pendingRequests = []; + for (let [req, res] of pendingRequests) { + this.respond(req, res); + } + } + } + + async buildError(options: PluginOptions, diagnostics: Array) { + this.pending = false; + this.errors = await Promise.all( + diagnostics.map(async (d) => { + let ansiDiagnostic = await prettyDiagnostic(d, options); + + return { + message: ansiHtml(ansiDiagnostic.message), + stack: ansiDiagnostic.stack ? ansiHtml(ansiDiagnostic.stack) : null, + frames: ansiDiagnostic.frames.map((f) => ({ + location: f.location, + code: ansiHtml(f.code), + })), + hints: ansiDiagnostic.hints.map((hint) => ansiHtml(hint)), + documentation: d.documentationURL ?? '', + }; + }), + ); + } + + respond(req: Request, res: Response): unknown { + if (this.middleware.some((handler) => handler(req, res))) return; + let {pathname, search} = url.parse(req.originalUrl || req.url); + if (pathname == null) { + pathname = '/'; + } + + if (pathname.startsWith(EDITOR_ENDPOINT) && search) { + let query = new URLSearchParams(search); + let file = query.get('file'); + if (file) { + // File location might start with /__parcel_source_root if it came from a source map. + if (file.startsWith(SOURCES_ENDPOINT)) { + file = file.slice(SOURCES_ENDPOINT.length + 1); + } + launchEditor(file); + } + res.end(); + } else if (this.errors) { + return this.send500(req, res); + } else if (path.extname(pathname) === '') { + // If the URL doesn't start with the public path, or the URL doesn't + // have a file extension, send the main HTML bundle. + return this.sendIndex(req, res); + } else if (pathname.startsWith(SOURCES_ENDPOINT)) { + req.url = pathname.slice(SOURCES_ENDPOINT.length); + return this.serve( + this.options.inputFS, + this.options.projectRoot, + req, + res, + () => this.send404(req, res), + ); + } else if (pathname.startsWith(this.rootPath)) { + // Otherwise, serve the file from the dist folder + req.url = + this.rootPath === '/' ? pathname : pathname.slice(this.rootPath.length); + if (req.url[0] !== '/') { + req.url = '/' + req.url; + } + return this.serveBundle(req, res, () => this.sendIndex(req, res)); + } else { + return this.send404(req, res); + } + } + + sendIndex(req: Request, res: Response) { + if (this.bundleGraph) { + // If the main asset is an HTML file, serve it + let htmlBundleFilePaths = this.bundleGraph + .getBundles() + .filter((bundle) => path.posix.extname(bundle.name) === '.html') + .map((bundle) => { + return `/${relativePath( + this.options.distDir, + bundle.filePath, + false, + )}`; + }); + + let indexFilePath = null; + let {pathname: reqURL} = url.parse(req.originalUrl || req.url); + + if (!reqURL) { + reqURL = '/'; + } + + if (htmlBundleFilePaths.length === 1) { + indexFilePath = htmlBundleFilePaths[0]; + } else { + let bestMatch = null; + for (let bundle of htmlBundleFilePaths) { + let bundleDir = path.posix.dirname(bundle); + let bundleDirSubdir = bundleDir === '/' ? bundleDir : bundleDir + '/'; + let withoutExtension = path.posix.basename( + bundle, + path.posix.extname(bundle), + ); + let isIndex = withoutExtension === 'index'; + + let matchesIsIndex = null; + if ( + isIndex && + (reqURL.startsWith(bundleDirSubdir) || reqURL === bundleDir) + ) { + // bundle is /bar/index.html and (/bar or something inside of /bar/** was requested was requested) + matchesIsIndex = true; + } else if (reqURL == path.posix.join(bundleDir, withoutExtension)) { + // bundle is /bar/foo.html and /bar/foo was requested + matchesIsIndex = false; + } + if (matchesIsIndex != null) { + let depth = bundle.match(SLASH_REGEX)?.length ?? 0; + if ( + bestMatch == null || + // This one is more specific (deeper) + bestMatch.depth < depth || + // This one is just as deep, but the bundle name matches and not just index.html + (bestMatch.depth === depth && bestMatch.isIndex) + ) { + bestMatch = {bundle, depth, isIndex: matchesIsIndex}; + } + } + } + indexFilePath = bestMatch?.['bundle'] ?? htmlBundleFilePaths[0]; + } + + if (indexFilePath) { + req.url = indexFilePath; + this.serveBundle(req, res, () => this.send404(req, res)); + } else { + this.send404(req, res); + } + } else { + this.send404(req, res); + } + } + + async serveBundle( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + let bundleGraph = this.bundleGraph; + if (bundleGraph) { + let {pathname} = url.parse(req.url); + if (!pathname) { + this.send500(req, res); + return; + } + + let requestedPath = path.normalize(pathname.slice(1)); + let bundle = bundleGraph + .getBundles() + .find( + (b) => + path.relative(this.options.distDir, b.filePath) === requestedPath, + ); + if (!bundle) { + this.serveDist(req, res, next); + return; + } + + invariant(this.requestBundle != null); + try { + await this.requestBundle(bundle); + } catch (err: any) { + this.send500(req, res); + return; + } + + this.serveDist(req, res, next); + } else { + this.send404(req, res); + } + } + + serveDist( + req: Request, + res: Response, + next: NextFunction, + ): Promise | Promise { + return this.serve( + this.options.outputFS, + this.options.distDir, + req, + res, + next, + ); + } + + async serve( + fs: FileSystem, + root: FilePath, + req: Request, + res: Response, + next: NextFunction, + ): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') { + // method not allowed + res.statusCode = 405; + res.setHeader('Allow', 'GET, HEAD'); + res.setHeader('Content-Length', '0'); + res.end(); + return; + } + + try { + var filePath = url.parse(req.url).pathname || ''; + filePath = decodeURIComponent(filePath); + } catch (err: any) { + return this.sendError(res, 400); + } + + filePath = path.normalize('.' + path.sep + filePath); + + // malicious path + if (filePath.includes(path.sep + '..' + path.sep)) { + return this.sendError(res, 403); + } + + // join / normalize from the root dir + if (!path.isAbsolute(filePath)) { + filePath = path.normalize(path.join(root, filePath)); + } + + try { + var stat = await fs.stat(filePath); + } catch (err: any) { + if (err.code === 'ENOENT') { + return next(req, res); + } + + return this.sendError(res, 500); + } + + // Fall back to next handler if not a file + if (!stat || !stat.isFile()) { + return next(req, res); + } + + if (fresh(req.headers, {'last-modified': stat.mtime.toUTCString()})) { + res.statusCode = 304; + res.end(); + return; + } + + return serveHandler( + req, + res, + { + public: root, + cleanUrls: false, + }, + { + // @ts-expect-error - TS7006 - Parameter 'path' implicitly has an 'any' type. + lstat: (path) => fs.stat(path), + // @ts-expect-error - TS7006 - Parameter 'path' implicitly has an 'any' type. + realpath: (path) => fs.realpath(path), + // @ts-expect-error - TS7006 - Parameter 'path' implicitly has an 'any' type. | TS7006 - Parameter 'options' implicitly has an 'any' type. + createReadStream: (path, options) => fs.createReadStream(path, options), + // @ts-expect-error - TS7006 - Parameter 'path' implicitly has an 'any' type. + readdir: (path) => fs.readdir(path), + }, + ); + } + + sendError(res: Response, statusCode: number) { + res.statusCode = statusCode; + res.end(); + } + + send404(req: Request, res: Response) { + res.statusCode = 404; + res.end(TEMPLATE_404); + } + + send500(req: Request, res: Response): undefined | Response { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.writeHead(500); + + if (this.errors) { + return res.end( + ejs.render(TEMPLATE_500, { + errors: this.errors, + hmrOptions: this.options.hmrOptions, + }), + ); + } + } + + logAccessIfVerbose(req: Request) { + this.options.logger.verbose({ + message: `Request: ${req.headers.host}${req.originalUrl || req.url}`, + }); + } + + /** + * Load proxy table from package.json and apply them. + */ + async applyProxyTable(app: any): Promise { + // avoid skipping project root + const fileInRoot: string = path.join(this.options.projectRoot, 'index'); + + const configFilePath = await resolveConfig( + this.options.inputFS, + fileInRoot, + [ + '.proxyrc.cts', + '.proxyrc.mts', + '.proxyrc.ts', + '.proxyrc.cjs', + '.proxyrc.mjs', + '.proxyrc.js', + '.proxyrc', + '.proxyrc.json', + ], + this.options.projectRoot, + ); + + if (!configFilePath) { + return this; + } + + const filename = path.basename(configFilePath); + + if (filename === '.proxyrc' || filename === '.proxyrc.json') { + let conf = await readConfig(this.options.inputFS, configFilePath); + if (!conf) { + return this; + } + let cfg = conf.config; + if (typeof cfg !== 'object') { + this.options.logger.warn({ + message: + "Proxy table in '.proxyrc' should be of object type. Skipping...", + }); + return this; + } + for (const [context, options] of Object.entries(cfg)) { + // each key is interpreted as context, and value as middleware options + // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Options | undefined'. + app.use(createProxyMiddleware(context, options)); + } + } else { + let cfg = await this.options.packageManager.require( + configFilePath, + fileInRoot, + ); + if ( + // $FlowFixMe + Object.prototype.toString.call(cfg) === '[object Module]' + ) { + cfg = cfg.default; + } + + if (typeof cfg !== 'function') { + this.options.logger.warn({ + message: `Proxy configuration file '${filename}' should export a function. Skipping...`, + }); + return this; + } + cfg(app); + } + + return this; + } + + async start(): Promise { + const finalHandler = (req: Request, res: Response) => { + this.logAccessIfVerbose(req); + + // Wait for the parcelInstance to finish bundling if needed + if (this.pending) { + this.pendingRequests.push([req, res]); + } else { + this.respond(req, res); + } + }; + + const app = connect(); + // @ts-expect-error - TS7006 - Parameter 'req' implicitly has an 'any' type. | TS7006 - Parameter 'res' implicitly has an 'any' type. | TS7006 - Parameter 'next' implicitly has an 'any' type. + app.use((req, res, next) => { + setHeaders(res); + next(); + }); + + // @ts-expect-error - TS7006 - Parameter 'req' implicitly has an 'any' type. | TS7006 - Parameter 'res' implicitly has an 'any' type. | TS7006 - Parameter 'next' implicitly has an 'any' type. + app.use((req, res, next) => { + if (req.url === '/__parcel_healthcheck') { + res.statusCode = 200; + res.write(`${Date.now()}`); + res.end(); + } else { + next(); + } + }); + + await this.applyProxyTable(app); + app.use(finalHandler); + + let {server, stop} = await createHTTPServer({ + cacheDir: this.options.cacheDir, + https: this.options.https, + inputFS: this.options.inputFS, + listener: app, + outputFS: this.options.outputFS, + host: this.options.host, + }); + this.stopServer = stop; + + server.listen(this.options.port, this.options.host); + // @ts-expect-error - TS2322 - Type 'Server' is not assignable to type 'HTTPServer'. + return new Promise( + ( + resolve: (result: Promise | Server) => void, + reject: (error?: any) => void, + ) => { + server.once('error', (err) => { + this.options.logger.error({ + // @ts-expect-error - TS2345 - Argument of type 'Error' is not assignable to parameter of type 'ServerError'. + message: serverErrors(err, this.options.port), + } as Diagnostic); + reject(err); + }); + + server.once('listening', () => { + // @ts-expect-error - TS2345 - Argument of type 'HTTPServer' is not assignable to parameter of type 'Server | Promise'. + resolve(server); + }); + }, + ); + } + + async stop(): Promise { + invariant(this.stopServer != null); + await this.stopServer(); + // @ts-expect-error - TS2322 - Type 'null' is not assignable to type '() => Promise | null | undefined'. + this.stopServer = null; + } +} diff --git a/packages/reporters/dev-server/src/ServerReporter.js b/packages/reporters/dev-server/src/ServerReporter.js deleted file mode 100644 index 3867b4e6e..000000000 --- a/packages/reporters/dev-server/src/ServerReporter.js +++ /dev/null @@ -1,143 +0,0 @@ -// @flow - -import {Reporter} from '@atlaspack/plugin'; -import HMRServer from './HMRServer'; -import Server from './Server'; - -let servers: Map = new Map(); -let hmrServers: Map = new Map(); -export default (new Reporter({ - async report({event, options, logger}) { - let {serveOptions, hmrOptions} = options; - let server = serveOptions ? servers.get(serveOptions.port) : undefined; - let hmrPort = - (hmrOptions && hmrOptions.port) || (serveOptions && serveOptions.port); - let hmrServer = hmrPort ? hmrServers.get(hmrPort) : undefined; - switch (event.type) { - case 'watchStart': { - if (serveOptions) { - // If there's already a server when watching has just started, something - // is wrong. - if (server) { - return logger.warn({ - message: 'Trying to create the devserver but it already exists.', - }); - } - - let serverOptions = { - ...serveOptions, - projectRoot: options.projectRoot, - cacheDir: options.cacheDir, - // Override the target's publicUrl as that is likely meant for production. - // This could be configurable in the future. - publicUrl: serveOptions.publicUrl ?? '/', - inputFS: options.inputFS, - outputFS: options.outputFS, - packageManager: options.packageManager, - logger, - hmrOptions, - }; - - server = new Server(serverOptions); - servers.set(serveOptions.port, server); - const devServer = await server.start(); - - if (hmrOptions && hmrOptions.port === serveOptions.port) { - let hmrServerOptions = { - port: serveOptions.port, - host: hmrOptions.host, - devServer, - addMiddleware: handler => { - server?.middleware.push(handler); - }, - logger, - https: options.serveOptions ? options.serveOptions.https : false, - cacheDir: options.cacheDir, - inputFS: options.inputFS, - outputFS: options.outputFS, - }; - hmrServer = new HMRServer(hmrServerOptions); - hmrServers.set(serveOptions.port, hmrServer); - await hmrServer.start(); - return; - } - } - - let port = hmrOptions?.port; - if (typeof port === 'number') { - let hmrServerOptions = { - port, - host: hmrOptions?.host, - logger, - https: options.serveOptions ? options.serveOptions.https : false, - cacheDir: options.cacheDir, - inputFS: options.inputFS, - outputFS: options.outputFS, - }; - hmrServer = new HMRServer(hmrServerOptions); - hmrServers.set(port, hmrServer); - await hmrServer.start(); - } - break; - } - case 'watchEnd': - if (serveOptions) { - if (!server) { - return logger.warn({ - message: - 'Could not shutdown devserver because it does not exist.', - }); - } - await server.stop(); - servers.delete(server.options.port); - } - if (hmrOptions && hmrServer) { - await hmrServer.stop(); - // $FlowFixMe[prop-missing] - hmrServers.delete(hmrServer.wss.options.port); - } - break; - case 'buildStart': - if (server) { - server.buildStart(); - } - break; - case 'buildProgress': - if ( - event.phase === 'bundled' && - hmrServer && - // Only send HMR updates before packaging if the built in dev server is used to ensure that - // no stale bundles are served. Otherwise emit it for 'buildSuccess'. - options.serveOptions !== false - ) { - await hmrServer.emitUpdate(event); - } - break; - case 'buildSuccess': - if (serveOptions) { - if (!server) { - return logger.warn({ - message: - 'Could not send success event to devserver because it does not exist.', - }); - } - - server.buildSuccess(event.bundleGraph, event.requestBundle); - } - if (hmrServer && options.serveOptions === false) { - await hmrServer.emitUpdate(event); - } - break; - case 'buildFailure': - // On buildFailure watchStart sometimes has not been called yet - // do not throw an additional warning here - if (server) { - await server.buildError(options, event.diagnostics); - } - if (hmrServer) { - await hmrServer.emitError(options, event.diagnostics); - } - break; - } - }, -}): Reporter); diff --git a/packages/reporters/dev-server/src/ServerReporter.ts b/packages/reporters/dev-server/src/ServerReporter.ts new file mode 100644 index 000000000..981a00f3d --- /dev/null +++ b/packages/reporters/dev-server/src/ServerReporter.ts @@ -0,0 +1,141 @@ +import {Reporter} from '@atlaspack/plugin'; +import HMRServer from './HMRServer'; +import Server from './Server'; + +let servers: Map = new Map(); +let hmrServers: Map = new Map(); +export default new Reporter({ + async report({event, options, logger}) { + let {serveOptions, hmrOptions} = options; + let server = serveOptions ? servers.get(serveOptions.port) : undefined; + let hmrPort = + (hmrOptions && hmrOptions.port) || (serveOptions && serveOptions.port); + let hmrServer = hmrPort ? hmrServers.get(hmrPort) : undefined; + switch (event.type) { + case 'watchStart': { + if (serveOptions) { + // If there's already a server when watching has just started, something + // is wrong. + if (server) { + return logger.warn({ + message: 'Trying to create the devserver but it already exists.', + }); + } + + let serverOptions = { + ...serveOptions, + projectRoot: options.projectRoot, + cacheDir: options.cacheDir, + // Override the target's publicUrl as that is likely meant for production. + // This could be configurable in the future. + publicUrl: serveOptions.publicUrl ?? '/', + inputFS: options.inputFS, + outputFS: options.outputFS, + packageManager: options.packageManager, + logger, + hmrOptions, + }; + + server = new Server(serverOptions); + servers.set(serveOptions.port, server); + const devServer = await server.start(); + + if (hmrOptions && hmrOptions.port === serveOptions.port) { + let hmrServerOptions = { + port: serveOptions.port, + host: hmrOptions.host, + devServer, + // @ts-expect-error - TS7006 - Parameter 'handler' implicitly has an 'any' type. + addMiddleware: (handler) => { + server?.middleware.push(handler); + }, + logger, + https: options.serveOptions ? options.serveOptions.https : false, + cacheDir: options.cacheDir, + inputFS: options.inputFS, + outputFS: options.outputFS, + }; + hmrServer = new HMRServer(hmrServerOptions); + hmrServers.set(serveOptions.port, hmrServer); + await hmrServer.start(); + return; + } + } + + let port = hmrOptions?.port; + if (typeof port === 'number') { + let hmrServerOptions = { + port, + host: hmrOptions?.host, + logger, + https: options.serveOptions ? options.serveOptions.https : false, + cacheDir: options.cacheDir, + inputFS: options.inputFS, + outputFS: options.outputFS, + }; + hmrServer = new HMRServer(hmrServerOptions); + hmrServers.set(port, hmrServer); + await hmrServer.start(); + } + break; + } + case 'watchEnd': + if (serveOptions) { + if (!server) { + return logger.warn({ + message: + 'Could not shutdown devserver because it does not exist.', + }); + } + await server.stop(); + servers.delete(server.options.port); + } + if (hmrOptions && hmrServer) { + await hmrServer.stop(); + hmrServers.delete(hmrServer.wss.options.port); + } + break; + case 'buildStart': + if (server) { + server.buildStart(); + } + break; + case 'buildProgress': + if ( + event.phase === 'bundled' && + hmrServer && + // Only send HMR updates before packaging if the built in dev server is used to ensure that + // no stale bundles are served. Otherwise emit it for 'buildSuccess'. + options.serveOptions !== false + ) { + await hmrServer.emitUpdate(event); + } + break; + case 'buildSuccess': + if (serveOptions) { + if (!server) { + return logger.warn({ + message: + 'Could not send success event to devserver because it does not exist.', + }); + } + + server.buildSuccess(event.bundleGraph, event.requestBundle); + } + if (hmrServer && options.serveOptions === false) { + await hmrServer.emitUpdate(event); + } + break; + case 'buildFailure': + // On buildFailure watchStart sometimes has not been called yet + // do not throw an additional warning here + if (server) { + await server.buildError(options, event.diagnostics); + } + if (hmrServer) { + await hmrServer.emitError(options, event.diagnostics); + } + break; + } + }, +}) as Reporter; diff --git a/packages/reporters/dev-server/src/serverErrors.js b/packages/reporters/dev-server/src/serverErrors.js deleted file mode 100644 index 05ee4166f..000000000 --- a/packages/reporters/dev-server/src/serverErrors.js +++ /dev/null @@ -1,21 +0,0 @@ -// @flow -export type ServerError = Error & {| - code: string, -|}; - -const serverErrorList = { - EACCES: "You don't have access to bind the server to port {port}.", - EADDRINUSE: 'There is already a process listening on port {port}.', -}; - -export default function serverErrors(err: ServerError, port: number): string { - let desc = `Error: ${ - err.code - } occurred while setting up server on port ${port.toString()}.`; - - if (serverErrorList[err.code]) { - desc = serverErrorList[err.code].replace(/{port}/g, port); - } - - return desc; -} diff --git a/packages/reporters/dev-server/src/serverErrors.ts b/packages/reporters/dev-server/src/serverErrors.ts new file mode 100644 index 000000000..b50ef1473 --- /dev/null +++ b/packages/reporters/dev-server/src/serverErrors.ts @@ -0,0 +1,22 @@ +export type ServerError = Error & { + code: string; +}; + +const serverErrorList = { + EACCES: "You don't have access to bind the server to port {port}.", + EADDRINUSE: 'There is already a process listening on port {port}.', +} as const; + +export default function serverErrors(err: ServerError, port: number): string { + let desc = `Error: ${ + err.code + } occurred while setting up server on port ${port.toString()}.`; + + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly EACCES: "You don't have access to bind the server to port {port}."; readonly EADDRINUSE: "There is already a process listening on port {port}."; }'. + if (serverErrorList[err.code]) { + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly EACCES: "You don't have access to bind the server to port {port}."; readonly EADDRINUSE: "There is already a process listening on port {port}."; }'. + desc = serverErrorList[err.code].replace(/{port}/g, port); + } + + return desc; +} diff --git a/packages/reporters/json/package.json b/packages/reporters/json/package.json index a5371e207..c956dca6c 100644 --- a/packages/reporters/json/package.json +++ b/packages/reporters/json/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/JSONReporter.js", - "source": "src/JSONReporter.js", + "types": "src/JSONReporter.ts", + "source": "src/JSONReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/json/src/JSONReporter.js b/packages/reporters/json/src/JSONReporter.js deleted file mode 100644 index 28197433d..000000000 --- a/packages/reporters/json/src/JSONReporter.js +++ /dev/null @@ -1,176 +0,0 @@ -// @flow strict-local -import type {BuildProgressEvent, LogEvent} from '@atlaspack/types'; -import type {BuildMetrics} from '@atlaspack/utils'; - -import {Reporter} from '@atlaspack/plugin'; -import {generateBuildMetrics} from '@atlaspack/utils'; - -/* eslint-disable no-console */ -const writeToStdout = makeWriter(console.log); -const writeToStderr = makeWriter(console.error); -/* eslint-enable no-console */ - -const LOG_LEVELS = { - none: 0, - error: 1, - warn: 2, - info: 3, - progress: 3, - success: 3, - verbose: 4, -}; - -export default (new Reporter({ - async report({event, options}) { - let logLevelFilter = options.logLevel || 'info'; - - switch (event.type) { - case 'buildStart': - if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.info) { - writeToStdout({type: 'buildStart'}, logLevelFilter); - } - break; - case 'buildFailure': - if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.error) { - writeToStderr( - {type: 'buildFailure', message: event.diagnostics[0].message}, - logLevelFilter, - ); - } - break; - case 'buildProgress': - if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.progress) { - let jsonEvent = progressEventToJSONEvent(event); - if (jsonEvent != null) { - writeToStdout(jsonEvent, logLevelFilter); - } - } - break; - case 'buildSuccess': - if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.success) { - let {bundles} = await generateBuildMetrics( - event.bundleGraph.getBundles(), - options.outputFS, - options.projectRoot, - ); - - writeToStdout( - { - type: 'buildSuccess', - buildTime: event.buildTime, - bundles: bundles, - }, - logLevelFilter, - ); - } - break; - case 'log': - writeLogEvent(event, logLevelFilter); - } - }, -}): Reporter); - -function makeWriter( - write: string => mixed, -): (JSONReportEvent, $Keys) => void { - return ( - event: JSONReportEvent, - logLevelFilter: $Keys, - ): void => { - let stringified; - try { - stringified = JSON.stringify(event); - } catch (err) { - // This should never happen so long as JSONReportEvent is easily serializable - if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.error) { - writeToStderr( - { - type: 'log', - level: 'error', - diagnostics: [ - { - origin: '@atlaspack/reporter-json', - message: err.message, - stack: err.stack, - }, - ], - }, - logLevelFilter, - ); - } - return; - } - - write(stringified); - }; -} - -function writeLogEvent( - event: LogEvent, - logLevelFilter: $Keys, -): void { - if (LOG_LEVELS[logLevelFilter] < LOG_LEVELS[event.level]) { - return; - } - switch (event.level) { - case 'info': - case 'progress': - case 'success': - case 'verbose': - writeToStdout(event, logLevelFilter); - break; - case 'warn': - case 'error': - writeToStderr(event, logLevelFilter); - break; - } -} - -function progressEventToJSONEvent( - progressEvent: BuildProgressEvent, -): ?JSONProgressEvent { - switch (progressEvent.phase) { - case 'transforming': - return { - type: 'buildProgress', - phase: 'transforming', - filePath: progressEvent.filePath, - }; - case 'bundling': - return { - type: 'buildProgress', - phase: 'bundling', - }; - case 'optimizing': - case 'packaging': - return { - type: 'buildProgress', - phase: progressEvent.phase, - bundleName: progressEvent.bundle.displayName, - }; - } -} - -type JSONReportEvent = - | LogEvent - | {|+type: 'buildStart'|} - | {|+type: 'buildFailure', message: string|} - | {| - +type: 'buildSuccess', - buildTime: number, - bundles?: $PropertyType, - |} - | JSONProgressEvent; - -type JSONProgressEvent = - | {| - +type: 'buildProgress', - phase: 'transforming', - filePath: string, - |} - | {|+type: 'buildProgress', phase: 'bundling'|} - | {| - +type: 'buildProgress', - +phase: 'packaging' | 'optimizing', - bundleName?: string, - |}; diff --git a/packages/reporters/json/src/JSONReporter.ts b/packages/reporters/json/src/JSONReporter.ts new file mode 100644 index 000000000..e20d32cbb --- /dev/null +++ b/packages/reporters/json/src/JSONReporter.ts @@ -0,0 +1,183 @@ +import type {BuildProgressEvent, LogEvent} from '@atlaspack/types'; +import type {BuildMetrics} from '@atlaspack/utils'; + +import {Reporter} from '@atlaspack/plugin'; +import {generateBuildMetrics} from '@atlaspack/utils'; + +/* eslint-disable no-console */ +const writeToStdout = makeWriter(console.log); +const writeToStderr = makeWriter(console.error); +/* eslint-enable no-console */ + +const LOG_LEVELS = { + none: 0, + error: 1, + warn: 2, + info: 3, + progress: 3, + success: 3, + verbose: 4, +} as const; + +export default new Reporter({ + async report({event, options}) { + let logLevelFilter = options.logLevel || 'info'; + + switch (event.type) { + case 'buildStart': + if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.info) { + writeToStdout({type: 'buildStart'}, logLevelFilter); + } + break; + case 'buildFailure': + if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.error) { + writeToStderr( + {type: 'buildFailure', message: event.diagnostics[0].message}, + logLevelFilter, + ); + } + break; + case 'buildProgress': + if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.progress) { + let jsonEvent = progressEventToJSONEvent(event); + if (jsonEvent != null) { + writeToStdout(jsonEvent, logLevelFilter); + } + } + break; + case 'buildSuccess': + if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.success) { + let {bundles} = await generateBuildMetrics( + event.bundleGraph.getBundles(), + options.outputFS, + options.projectRoot, + ); + + writeToStdout( + { + type: 'buildSuccess', + buildTime: event.buildTime, + bundles: bundles, + }, + logLevelFilter, + ); + } + break; + case 'log': + writeLogEvent(event, logLevelFilter); + } + }, +}) as Reporter; + +function makeWriter( + write: (arg1: string) => unknown, +): (arg1: JSONReportEvent, arg2: keyof typeof LOG_LEVELS) => void { + return ( + event: JSONReportEvent, + logLevelFilter: keyof typeof LOG_LEVELS, + ): void => { + let stringified; + try { + stringified = JSON.stringify(event); + } catch (err: any) { + // This should never happen so long as JSONReportEvent is easily serializable + if (LOG_LEVELS[logLevelFilter] >= LOG_LEVELS.error) { + writeToStderr( + { + type: 'log', + level: 'error', + diagnostics: [ + { + origin: '@atlaspack/reporter-json', + message: err.message, + stack: err.stack, + }, + ], + }, + logLevelFilter, + ); + } + return; + } + + write(stringified); + }; +} + +function writeLogEvent( + event: LogEvent, + logLevelFilter: keyof typeof LOG_LEVELS, +): void { + if (LOG_LEVELS[logLevelFilter] < LOG_LEVELS[event.level]) { + return; + } + switch (event.level) { + case 'info': + case 'progress': + case 'success': + case 'verbose': + writeToStdout(event, logLevelFilter); + break; + case 'warn': + case 'error': + writeToStderr(event, logLevelFilter); + break; + } +} + +function progressEventToJSONEvent( + progressEvent: BuildProgressEvent, +): JSONProgressEvent | null | undefined { + switch (progressEvent.phase) { + case 'transforming': + return { + type: 'buildProgress', + phase: 'transforming', + filePath: progressEvent.filePath, + }; + case 'bundling': + return { + type: 'buildProgress', + phase: 'bundling', + }; + case 'optimizing': + case 'packaging': + return { + type: 'buildProgress', + phase: progressEvent.phase, + bundleName: progressEvent.bundle.displayName, + }; + } +} + +type JSONReportEvent = + | LogEvent + | { + readonly type: 'buildStart'; + } + | { + readonly type: 'buildFailure'; + message: string; + } + | { + readonly type: 'buildSuccess'; + buildTime: number; + bundles?: BuildMetrics['bundles']; + } + | JSONProgressEvent; + +type JSONProgressEvent = + | { + readonly type: 'buildProgress'; + phase: 'transforming'; + filePath: string; + } + | { + readonly type: 'buildProgress'; + phase: 'bundling'; + } + | { + readonly type: 'buildProgress'; + readonly phase: 'packaging' | 'optimizing'; + bundleName?: string; + }; diff --git a/packages/reporters/lsp-reporter/package.json b/packages/reporters/lsp-reporter/package.json index 9d6c44401..8dadf2adf 100644 --- a/packages/reporters/lsp-reporter/package.json +++ b/packages/reporters/lsp-reporter/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/LspReporter.js", - "source": "src/LspReporter.js", + "types": "src/LspReporter.ts", + "source": "src/LspReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/lsp-reporter/src/LspReporter.js b/packages/reporters/lsp-reporter/src/LspReporter.js deleted file mode 100644 index ab196b822..000000000 --- a/packages/reporters/lsp-reporter/src/LspReporter.js +++ /dev/null @@ -1,455 +0,0 @@ -// @flow strict-local - -import type {Diagnostic as ParcelDiagnostic} from '@atlaspack/diagnostic'; -import type {BundleGraph, FilePath, PackagedBundle} from '@atlaspack/types'; -import type {Program, Query} from 'ps-node'; -import type {Diagnostic, DocumentUri} from 'vscode-languageserver'; -import type {MessageConnection} from 'vscode-jsonrpc/node'; -import type {ParcelSeverity} from './utils'; - -import { - DefaultMap, - getProgressMessage, - makeDeferredWithPromise, -} from '@atlaspack/utils'; -import {Reporter} from '@atlaspack/plugin'; -import path from 'path'; -import os from 'os'; -import url from 'url'; -import fs from 'fs'; -import nullthrows from 'nullthrows'; -import * as ps from 'ps-node'; -import {promisify} from 'util'; - -import {createServer} from './ipc'; -import { - type PublishDiagnostic, - NotificationBuildStatus, - NotificationWorkspaceDiagnostics, - RequestDocumentDiagnostics, - RequestImporters, -} from '@atlaspack/lsp-protocol'; - -import { - DiagnosticSeverity, - DiagnosticTag, - normalizeFilePath, - parcelSeverityToLspSeverity, -} from './utils'; -import type {FSWatcher} from 'fs'; - -const lookupPid: Query => Program[] = promisify(ps.lookup); - -const ignoreFail = func => { - try { - func(); - } catch (e) { - /**/ - } -}; - -const BASEDIR = fs.realpathSync(path.join(os.tmpdir(), 'parcel-lsp')); -const SOCKET_FILE = path.join(BASEDIR, `parcel-${process.pid}`); -const META_FILE = path.join(BASEDIR, `parcel-${process.pid}.json`); - -let workspaceDiagnostics: DefaultMap< - string, - Array, -> = new DefaultMap(() => []); - -const getWorkspaceDiagnostics = (): Array => - [...workspaceDiagnostics].map(([uri, diagnostics]) => ({uri, diagnostics})); - -let server; -let connections: Array = []; - -let bundleGraphDeferrable = - makeDeferredWithPromise>(); -let bundleGraph: Promise> = - bundleGraphDeferrable.promise; - -let watchStarted = false; -let lspStarted = false; -let watchStartPromise; - -const LSP_SENTINEL_FILENAME = 'lsp-server'; -const LSP_SENTINEL_FILE = path.join(BASEDIR, LSP_SENTINEL_FILENAME); - -async function watchLspActive(): Promise { - // Check for lsp-server when reporter is first started - try { - await fs.promises.access(LSP_SENTINEL_FILE, fs.constants.F_OK); - lspStarted = true; - } catch { - // - } - - return fs.watch(BASEDIR, (eventType: string, filename: string) => { - switch (eventType) { - case 'rename': - if (filename === LSP_SENTINEL_FILENAME) { - fs.access(LSP_SENTINEL_FILE, fs.constants.F_OK, err => { - if (err) { - lspStarted = false; - } else { - lspStarted = true; - } - }); - } - } - }); -} - -async function doWatchStart(options) { - await fs.promises.mkdir(BASEDIR, {recursive: true}); - - // For each existing file, check if the pid matches a running process. - // If no process matches, delete the file, assuming it was orphaned - // by a process that quit unexpectedly. - for (let filename of fs.readdirSync(BASEDIR)) { - if (filename.endsWith('.json')) continue; - let pid = parseInt(filename.slice('parcel-'.length), 10); - let resultList = await lookupPid({pid}); - if (resultList.length > 0) continue; - fs.unlinkSync(path.join(BASEDIR, filename)); - ignoreFail(() => fs.unlinkSync(path.join(BASEDIR, filename + '.json'))); - } - - server = await createServer(SOCKET_FILE, connection => { - // console.log('got connection'); - connections.push(connection); - connection.onClose(() => { - connections = connections.filter(c => c !== connection); - }); - - connection.onRequest(RequestDocumentDiagnostics, async uri => { - let graph = await bundleGraph; - if (!graph) return; - - return getDiagnosticsUnusedExports(graph, uri); - }); - - connection.onRequest(RequestImporters, async params => { - let graph = await bundleGraph; - if (!graph) return null; - - return getImporters(graph, params); - }); - - sendDiagnostics(); - }); - await fs.promises.writeFile( - META_FILE, - JSON.stringify({ - projectRoot: options.projectRoot, - pid: process.pid, - argv: process.argv, - }), - ); -} - -watchLspActive(); - -export default (new Reporter({ - async report({event, options}) { - if (event.type === 'watchStart') { - watchStarted = true; - } - - if (watchStarted && lspStarted) { - if (!watchStartPromise) { - watchStartPromise = doWatchStart(options); - } - await watchStartPromise; - } - - switch (event.type) { - case 'watchStart': { - break; - } - - case 'buildStart': { - bundleGraphDeferrable = makeDeferredWithPromise(); - bundleGraph = bundleGraphDeferrable.promise; - updateBuildState('start'); - clearDiagnostics(); - break; - } - case 'buildSuccess': - bundleGraphDeferrable.deferred.resolve(event.bundleGraph); - updateBuildState('end'); - sendDiagnostics(); - break; - case 'buildFailure': { - bundleGraphDeferrable.deferred.resolve(undefined); - updateDiagnostics(event.diagnostics, 'error', options.projectRoot); - updateBuildState('end'); - sendDiagnostics(); - break; - } - case 'log': - if ( - event.diagnostics != null && - (event.level === 'error' || - event.level === 'warn' || - event.level === 'info' || - event.level === 'verbose') - ) { - updateDiagnostics( - event.diagnostics, - event.level, - options.projectRoot, - ); - } - break; - case 'buildProgress': { - let message = getProgressMessage(event); - if (message != null) { - updateBuildState('progress', message); - } - break; - } - case 'watchEnd': - connections.forEach(c => c.end()); - await server.close(); - ignoreFail(() => fs.unlinkSync(META_FILE)); - break; - } - }, -}): Reporter); - -function updateBuildState( - state: 'start' | 'progress' | 'end', - message: string | void, -) { - connections.forEach(c => - c.sendNotification(NotificationBuildStatus, state, message), - ); -} - -function clearDiagnostics() { - workspaceDiagnostics.clear(); -} -function sendDiagnostics() { - // console.log('send', getWorkspaceDiagnostics()); - connections.forEach(c => - c.sendNotification( - NotificationWorkspaceDiagnostics, - getWorkspaceDiagnostics(), - ), - ); -} - -function updateDiagnostics( - parcelDiagnostics: Array, - parcelSeverity: ParcelSeverity, - projectRoot: FilePath, -): void { - for (let diagnostic of parcelDiagnostics) { - const codeFrames = diagnostic.codeFrames; - if (codeFrames == null) { - continue; - } - - const firstCodeFrame = codeFrames[0]; - const filePath = firstCodeFrame.filePath; - if (filePath == null) { - continue; - } - - // We use the first highlight of the first codeFrame as the main Diagnostic, - // and we place everything else in the current Parcel diagnostic - // in relatedInformation - // https://code.visualstudio.com/api/references/vscode-api#DiagnosticRelatedInformation - const firstFrameHighlight = codeFrames[0].codeHighlights[0]; - if (firstFrameHighlight == null) { - continue; - } - - const relatedInformation = []; - for (const codeFrame of codeFrames) { - for (const highlight of codeFrame.codeHighlights) { - const filePath = codeFrame.filePath; - if (highlight === firstFrameHighlight || filePath == null) { - continue; - } - - relatedInformation.push({ - location: { - uri: `file://${normalizeFilePath(filePath, projectRoot)}`, - range: { - start: { - line: highlight.start.line - 1, - character: highlight.start.column - 1, - }, - end: { - line: highlight.end.line - 1, - character: highlight.end.column, - }, - }, - }, - message: highlight.message ?? diagnostic.message, - }); - } - } - - workspaceDiagnostics - .get(`file://${normalizeFilePath(filePath, projectRoot)}`) - .push({ - range: { - start: { - line: firstFrameHighlight.start.line - 1, - character: firstFrameHighlight.start.column - 1, - }, - end: { - line: firstFrameHighlight.end.line - 1, - character: firstFrameHighlight.end.column, - }, - }, - source: diagnostic.origin, - severity: parcelSeverityToLspSeverity(parcelSeverity), - message: - diagnostic.message + - (firstFrameHighlight.message == null - ? '' - : ' ' + firstFrameHighlight.message), - relatedInformation, - }); - } -} - -function getDiagnosticsUnusedExports( - bundleGraph: BundleGraph, - document: string, -): Array { - let filename = url.fileURLToPath(document); - let diagnostics = []; - - let asset = bundleGraph.traverse((node, context, actions) => { - if (node.type === 'asset' && node.value.filePath === filename) { - actions.stop(); - return node.value; - } - }); - - if (asset) { - const generateDiagnostic = (loc, type) => ({ - range: { - start: { - line: loc.start.line - 1, - character: loc.start.column - 1, - }, - end: { - line: loc.end.line - 1, - character: loc.end.column, - }, - }, - source: '@atlaspack/core', - severity: DiagnosticSeverity.Hint, - message: `Unused ${type}.`, - tags: [DiagnosticTag.Unnecessary], - }); - - let usedSymbols = bundleGraph.getUsedSymbols(asset); - if (usedSymbols) { - for (let [exported, symbol] of asset.symbols) { - if (!usedSymbols.has(exported)) { - if (symbol.loc) { - diagnostics.push(generateDiagnostic(symbol.loc, 'export')); - } - } - } - // if (usedSymbols.size === 0 && asset.sideEffects !== false) { - // diagnostics.push({ - // range: { - // start: { - // line: 0, - // character: 0, - // }, - // end: { - // line: 0, - // character: 1, - // }, - // }, - // source: '@atlaspack/core', - // severity: DiagnosticSeverity.Warning, - // message: `Asset has no used exports, but is not marked as sideEffect-free so it cannot be excluded automatically.`, - // }); - // } - } - - for (let dep of asset.getDependencies()) { - let usedSymbols = bundleGraph.getUsedSymbols(dep); - if (usedSymbols) { - for (let [exported, symbol] of dep.symbols) { - if (!usedSymbols.has(exported) && symbol.isWeak && symbol.loc) { - diagnostics.push(generateDiagnostic(symbol.loc, 'reexport')); - } - } - } - } - } - return diagnostics; -} - -// function getDefinition( -// bundleGraph: BundleGraph, -// document: string, -// position: Position, -// ): Array | void { -// let filename = url.fileURLToPath(document); - -// let asset = bundleGraph.traverse((node, context, actions) => { -// if (node.type === 'asset' && node.value.filePath === filename) { -// actions.stop(); -// return node.value; -// } -// }); - -// if (asset) { -// for (let dep of bundleGraph.getDependencies(asset)) { -// let loc = dep.loc; -// if (loc && isInRange(loc, position)) { -// let resolution = bundleGraph.getResolvedAsset(dep); -// if (resolution) { -// return [ -// { -// originSelectionRange: { -// start: { -// line: loc.start.line - 1, -// character: loc.start.column - 1, -// }, -// end: {line: loc.end.line - 1, character: loc.end.column}, -// }, -// targetUri: `file://${resolution.filePath}`, -// targetRange: RANGE_DUMMY, -// targetSelectionRange: RANGE_DUMMY, -// }, -// ]; -// } -// } -// } -// } -// } - -function getImporters( - bundleGraph: BundleGraph, - document: string, -): Array | null { - let filename = url.fileURLToPath(document); - - let asset = bundleGraph.traverse((node, context, actions) => { - if (node.type === 'asset' && node.value.filePath === filename) { - actions.stop(); - return node.value; - } - }); - - if (asset) { - let incoming = bundleGraph.getIncomingDependencies(asset); - return incoming - .filter(dep => dep.sourcePath != null) - .map(dep => `file://${nullthrows(dep.sourcePath)}`); - } - return null; -} diff --git a/packages/reporters/lsp-reporter/src/LspReporter.ts b/packages/reporters/lsp-reporter/src/LspReporter.ts new file mode 100644 index 000000000..2ffdbe048 --- /dev/null +++ b/packages/reporters/lsp-reporter/src/LspReporter.ts @@ -0,0 +1,473 @@ +import type {Diagnostic as ParcelDiagnostic} from '@atlaspack/diagnostic'; +import type {BundleGraph, FilePath, PackagedBundle} from '@atlaspack/types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'ps-node'. '/home/ubuntu/parcel/node_modules/ps-node/index.js' implicitly has an 'any' type. +import type {Program, Query} from 'ps-node'; +import type {Diagnostic, DocumentUri} from 'vscode-languageserver'; +import type {MessageConnection} from 'vscode-jsonrpc/node'; +import type {ParcelSeverity} from './utils'; + +import { + DefaultMap, + getProgressMessage, + makeDeferredWithPromise, +} from '@atlaspack/utils'; +import {Reporter} from '@atlaspack/plugin'; +import path from 'path'; +import os from 'os'; +import url from 'url'; +import fs from 'fs'; +import nullthrows from 'nullthrows'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'ps-node'. '/home/ubuntu/parcel/node_modules/ps-node/index.js' implicitly has an 'any' type. +import * as ps from 'ps-node'; +import {promisify} from 'util'; + +import {createServer} from './ipc'; +import { + // @ts-expect-error - TS2305 - Module '"@atlaspack/lsp-protocol"' has no exported member 'PublishDiagnostic'. + PublishDiagnostic, + NotificationBuildStatus, + NotificationWorkspaceDiagnostics, + RequestDocumentDiagnostics, + RequestImporters, +} from '@atlaspack/lsp-protocol'; + +import { + DiagnosticSeverity, + DiagnosticTag, + normalizeFilePath, + parcelSeverityToLspSeverity, +} from './utils'; +import type {FSWatcher} from 'fs'; + +const lookupPid: (arg1: Query) => Program[] = promisify(ps.lookup); + +const ignoreFail = (func: () => void) => { + try { + func(); + } catch (e: any) { + /**/ + } +}; + +const BASEDIR = fs.realpathSync(path.join(os.tmpdir(), 'parcel-lsp')); +const SOCKET_FILE = path.join(BASEDIR, `parcel-${process.pid}`); +const META_FILE = path.join(BASEDIR, `parcel-${process.pid}.json`); + +let workspaceDiagnostics: DefaultMap< + string, + Array +> = new DefaultMap(() => []); + +const getWorkspaceDiagnostics = (): Array => + [...workspaceDiagnostics].map(([uri, diagnostics]: [any, any]) => ({ + uri, + diagnostics, + })); + +// @ts-expect-error - TS7034 - Variable 'server' implicitly has type 'any' in some locations where its type cannot be determined. +let server; +let connections: Array = []; + +let bundleGraphDeferrable = makeDeferredWithPromise< + BundleGraph | null | undefined +>(); +let bundleGraph: Promise | null | undefined> = + bundleGraphDeferrable.promise; + +let watchStarted = false; +let lspStarted = false; +// @ts-expect-error - TS7034 - Variable 'watchStartPromise' implicitly has type 'any' in some locations where its type cannot be determined. +let watchStartPromise; + +const LSP_SENTINEL_FILENAME = 'lsp-server'; +const LSP_SENTINEL_FILE = path.join(BASEDIR, LSP_SENTINEL_FILENAME); + +async function watchLspActive(): Promise { + // Check for lsp-server when reporter is first started + try { + await fs.promises.access(LSP_SENTINEL_FILE, fs.constants.F_OK); + lspStarted = true; + } catch { + // + } + + // @ts-expect-error - TS2769 - No overload matches this call. + return fs.watch(BASEDIR, (eventType: string, filename: string) => { + switch (eventType) { + case 'rename': + if (filename === LSP_SENTINEL_FILENAME) { + fs.access(LSP_SENTINEL_FILE, fs.constants.F_OK, (err) => { + if (err) { + lspStarted = false; + } else { + lspStarted = true; + } + }); + } + } + }); +} + +async function doWatchStart(options: PluginOptions) { + await fs.promises.mkdir(BASEDIR, {recursive: true}); + + // For each existing file, check if the pid matches a running process. + // If no process matches, delete the file, assuming it was orphaned + // by a process that quit unexpectedly. + for (let filename of fs.readdirSync(BASEDIR)) { + if (filename.endsWith('.json')) continue; + let pid = parseInt(filename.slice('parcel-'.length), 10); + let resultList = await lookupPid({pid}); + if (resultList.length > 0) continue; + fs.unlinkSync(path.join(BASEDIR, filename)); + ignoreFail(() => fs.unlinkSync(path.join(BASEDIR, filename + '.json'))); + } + + server = await createServer(SOCKET_FILE, (connection) => { + // console.log('got connection'); + connections.push(connection); + connection.onClose(() => { + connections = connections.filter((c) => c !== connection); + }); + + connection.onRequest(RequestDocumentDiagnostics, async (uri) => { + let graph = await bundleGraph; + if (!graph) return; + + // @ts-expect-error - TS2345 - Argument of type 'CancellationToken' is not assignable to parameter of type 'string'. + return getDiagnosticsUnusedExports(graph, uri); + }); + + connection.onRequest(RequestImporters, async (params) => { + let graph = await bundleGraph; + if (!graph) return null; + + // @ts-expect-error - TS2345 - Argument of type 'CancellationToken' is not assignable to parameter of type 'string'. + return getImporters(graph, params); + }); + + sendDiagnostics(); + }); + await fs.promises.writeFile( + META_FILE, + JSON.stringify({ + projectRoot: options.projectRoot, + pid: process.pid, + argv: process.argv, + }), + ); +} + +watchLspActive(); + +export default new Reporter({ + async report({event, options}) { + if (event.type === 'watchStart') { + watchStarted = true; + } + + if (watchStarted && lspStarted) { + // @ts-expect-error - TS7005 - Variable 'watchStartPromise' implicitly has an 'any' type. + if (!watchStartPromise) { + watchStartPromise = doWatchStart(options); + } + // @ts-expect-error - TS7005 - Variable 'watchStartPromise' implicitly has an 'any' type. + await watchStartPromise; + } + + switch (event.type) { + case 'watchStart': { + break; + } + + case 'buildStart': { + bundleGraphDeferrable = makeDeferredWithPromise(); + bundleGraph = bundleGraphDeferrable.promise; + updateBuildState('start'); + clearDiagnostics(); + break; + } + case 'buildSuccess': + bundleGraphDeferrable.deferred.resolve(event.bundleGraph); + updateBuildState('end'); + sendDiagnostics(); + break; + case 'buildFailure': { + bundleGraphDeferrable.deferred.resolve(undefined); + updateDiagnostics(event.diagnostics, 'error', options.projectRoot); + updateBuildState('end'); + sendDiagnostics(); + break; + } + case 'log': + if ( + // @ts-expect-error - TS2339 - Property 'diagnostics' does not exist on type 'ProgressLogEvent | DiagnosticLogEvent | TextLogEvent'. + event.diagnostics != null && + (event.level === 'error' || + event.level === 'warn' || + event.level === 'info' || + event.level === 'verbose') + ) { + updateDiagnostics( + event.diagnostics, + event.level, + options.projectRoot, + ); + } + break; + case 'buildProgress': { + let message = getProgressMessage(event); + if (message != null) { + updateBuildState('progress', message); + } + break; + } + case 'watchEnd': + connections.forEach((c) => c.end()); + // @ts-expect-error - TS7005 - Variable 'server' implicitly has an 'any' type. + await server.close(); + ignoreFail(() => fs.unlinkSync(META_FILE)); + break; + } + }, +}) as Reporter; + +function updateBuildState( + state: 'start' | 'progress' | 'end', + message?: string, +) { + connections.forEach((c) => + c.sendNotification(NotificationBuildStatus, state, message), + ); +} + +function clearDiagnostics() { + workspaceDiagnostics.clear(); +} +function sendDiagnostics() { + // console.log('send', getWorkspaceDiagnostics()); + connections.forEach((c) => + c.sendNotification( + NotificationWorkspaceDiagnostics, + getWorkspaceDiagnostics(), + ), + ); +} + +function updateDiagnostics( + parcelDiagnostics: Array, + parcelSeverity: ParcelSeverity, + projectRoot: FilePath, +): void { + for (let diagnostic of parcelDiagnostics) { + const codeFrames = diagnostic.codeFrames; + if (codeFrames == null) { + continue; + } + + const firstCodeFrame = codeFrames[0]; + const filePath = firstCodeFrame.filePath; + if (filePath == null) { + continue; + } + + // We use the first highlight of the first codeFrame as the main Diagnostic, + // and we place everything else in the current Parcel diagnostic + // in relatedInformation + // https://code.visualstudio.com/api/references/vscode-api#DiagnosticRelatedInformation + const firstFrameHighlight = codeFrames[0].codeHighlights[0]; + if (firstFrameHighlight == null) { + continue; + } + + const relatedInformation: Array = []; + for (const codeFrame of codeFrames) { + for (const highlight of codeFrame.codeHighlights) { + const filePath = codeFrame.filePath; + if (highlight === firstFrameHighlight || filePath == null) { + continue; + } + + relatedInformation.push({ + location: { + uri: `file://${normalizeFilePath(filePath, projectRoot)}`, + range: { + start: { + line: highlight.start.line - 1, + character: highlight.start.column - 1, + }, + end: { + line: highlight.end.line - 1, + character: highlight.end.column, + }, + }, + }, + message: highlight.message ?? diagnostic.message, + }); + } + } + + workspaceDiagnostics + .get(`file://${normalizeFilePath(filePath, projectRoot)}`) + .push({ + range: { + start: { + line: firstFrameHighlight.start.line - 1, + character: firstFrameHighlight.start.column - 1, + }, + end: { + line: firstFrameHighlight.end.line - 1, + character: firstFrameHighlight.end.column, + }, + }, + source: diagnostic.origin, + severity: parcelSeverityToLspSeverity(parcelSeverity), + message: + diagnostic.message + + (firstFrameHighlight.message == null + ? '' + : ' ' + firstFrameHighlight.message), + relatedInformation, + }); + } +} + +function getDiagnosticsUnusedExports( + bundleGraph: BundleGraph, + document: string, +): Array { + let filename = url.fileURLToPath(document); + let diagnostics: Array = []; + + let asset = bundleGraph.traverse((node, context, actions) => { + if (node.type === 'asset' && node.value.filePath === filename) { + actions.stop(); + return node.value; + } + }); + + if (asset) { + const generateDiagnostic = (loc: SourceLocation, type: string) => ({ + range: { + start: { + line: loc.start.line - 1, + character: loc.start.column - 1, + }, + end: { + line: loc.end.line - 1, + character: loc.end.column, + }, + }, + source: '@atlaspack/core', + severity: DiagnosticSeverity.Hint, + message: `Unused ${type}.`, + tags: [DiagnosticTag.Unnecessary], + }); + + // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Dependency | Asset'. + let usedSymbols = bundleGraph.getUsedSymbols(asset); + if (usedSymbols) { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + for (let [exported, symbol] of asset.symbols) { + if (!usedSymbols.has(exported)) { + if (symbol.loc) { + diagnostics.push(generateDiagnostic(symbol.loc, 'export')); + } + } + } + // if (usedSymbols.size === 0 && asset.sideEffects !== false) { + // diagnostics.push({ + // range: { + // start: { + // line: 0, + // character: 0, + // }, + // end: { + // line: 0, + // character: 1, + // }, + // }, + // source: '@atlaspack/core', + // severity: DiagnosticSeverity.Warning, + // message: `Asset has no used exports, but is not marked as sideEffect-free so it cannot be excluded automatically.`, + // }); + // } + } + + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + for (let dep of asset.getDependencies()) { + let usedSymbols = bundleGraph.getUsedSymbols(dep); + if (usedSymbols) { + for (let [exported, symbol] of dep.symbols) { + if (!usedSymbols.has(exported) && symbol.isWeak && symbol.loc) { + diagnostics.push(generateDiagnostic(symbol.loc, 'reexport')); + } + } + } + } + } + return diagnostics; +} + +// function getDefinition( +// bundleGraph: BundleGraph, +// document: string, +// position: Position, +// ): Array | void { +// let filename = url.fileURLToPath(document); + +// let asset = bundleGraph.traverse((node, context, actions) => { +// if (node.type === 'asset' && node.value.filePath === filename) { +// actions.stop(); +// return node.value; +// } +// }); + +// if (asset) { +// for (let dep of bundleGraph.getDependencies(asset)) { +// let loc = dep.loc; +// if (loc && isInRange(loc, position)) { +// let resolution = bundleGraph.getResolvedAsset(dep); +// if (resolution) { +// return [ +// { +// originSelectionRange: { +// start: { +// line: loc.start.line - 1, +// character: loc.start.column - 1, +// }, +// end: {line: loc.end.line - 1, character: loc.end.column}, +// }, +// targetUri: `file://${resolution.filePath}`, +// targetRange: RANGE_DUMMY, +// targetSelectionRange: RANGE_DUMMY, +// }, +// ]; +// } +// } +// } +// } +// } + +function getImporters( + bundleGraph: BundleGraph, + document: string, +): Array | null { + let filename = url.fileURLToPath(document); + + let asset = bundleGraph.traverse((node, context, actions) => { + if (node.type === 'asset' && node.value.filePath === filename) { + actions.stop(); + return node.value; + } + }); + + if (asset) { + // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type 'Asset'. + let incoming = bundleGraph.getIncomingDependencies(asset); + return incoming + .filter((dep) => dep.sourcePath != null) + .map((dep) => `file://${nullthrows(dep.sourcePath)}`); + } + return null; +} diff --git a/packages/reporters/lsp-reporter/src/ipc.js b/packages/reporters/lsp-reporter/src/ipc.js deleted file mode 100644 index 4822a4ce3..000000000 --- a/packages/reporters/lsp-reporter/src/ipc.js +++ /dev/null @@ -1,55 +0,0 @@ -// @flow -import * as net from 'net'; -import type { - MessageReader, - MessageWriter, - MessageConnection, -} from 'vscode-jsonrpc/node'; -import { - createMessageConnection, - SocketMessageReader, - SocketMessageWriter, -} from 'vscode-jsonrpc/node'; - -function createClientPipeTransport( - pipeName: string, - onConnected: (reader: MessageReader, writer: MessageWriter) => void, -): Promise<{|close: () => Promise|}> { - return new Promise((resolve, reject) => { - let server: net.Server = net.createServer((socket: net.Socket) => { - onConnected( - new SocketMessageReader(socket), - new SocketMessageWriter(socket), - ); - }); - server.on('error', reject); - server.listen(pipeName, () => { - server.removeListener('error', reject); - resolve({ - close() { - return new Promise((res, rej) => { - server.close(e => { - if (e) rej(e); - else res(); - }); - }); - }, - }); - }); - }); -} - -export function createServer( - filename: string, - setup: (connection: MessageConnection) => void, -): Promise<{|close: () => Promise|}> { - return createClientPipeTransport( - filename, - (reader: MessageReader, writer: MessageWriter) => { - let connection = createMessageConnection(reader, writer); - connection.listen(); - - setup(connection); - }, - ); -} diff --git a/packages/reporters/lsp-reporter/src/ipc.ts b/packages/reporters/lsp-reporter/src/ipc.ts new file mode 100644 index 000000000..28feb616c --- /dev/null +++ b/packages/reporters/lsp-reporter/src/ipc.ts @@ -0,0 +1,77 @@ +import * as net from 'net'; +import type { + MessageReader, + MessageWriter, + MessageConnection, +} from 'vscode-jsonrpc/node'; +import { + createMessageConnection, + SocketMessageReader, + SocketMessageWriter, +} from 'vscode-jsonrpc/node'; + +function createClientPipeTransport( + pipeName: string, + onConnected: (reader: MessageReader, writer: MessageWriter) => void, +): Promise<{ + close: () => Promise; +}> { + return new Promise( + ( + resolve: ( + result: + | Promise<{ + close(): Promise; + }> + | { + close(): Promise; + }, + ) => void, + reject: (error?: any) => void, + ) => { + let server: net.Server = net.createServer((socket: net.Socket) => { + onConnected( + new SocketMessageReader(socket), + new SocketMessageWriter(socket), + ); + }); + server.on('error', reject); + server.listen(pipeName, () => { + server.removeListener('error', reject); + resolve({ + close() { + return new Promise( + ( + res: (result: Promise | undefined) => void, + rej: (error?: any) => void, + ) => { + server.close((e) => { + if (e) rej(e); + // @ts-expect-error - TS2794 - Expected 1 arguments, but got 0. Did you forget to include 'void' in your type argument to 'Promise'? + else res(); + }); + }, + ); + }, + }); + }); + }, + ); +} + +export function createServer( + filename: string, + setup: (connection: MessageConnection) => void, +): Promise<{ + close: () => Promise; +}> { + return createClientPipeTransport( + filename, + (reader: MessageReader, writer: MessageWriter) => { + let connection = createMessageConnection(reader, writer); + connection.listen(); + + setup(connection); + }, + ); +} diff --git a/packages/reporters/lsp-reporter/src/utils.js b/packages/reporters/lsp-reporter/src/utils.js deleted file mode 100644 index acd9310e3..000000000 --- a/packages/reporters/lsp-reporter/src/utils.js +++ /dev/null @@ -1,95 +0,0 @@ -// @flow -import type {DiagnosticLogEvent, FilePath} from '@atlaspack/types'; -import type {ODiagnosticSeverity} from 'vscode-languageserver'; - -import path from 'path'; - -export type ParcelSeverity = DiagnosticLogEvent['level']; - -export function parcelSeverityToLspSeverity( - parcelSeverity: ParcelSeverity, -): ODiagnosticSeverity { - switch (parcelSeverity) { - case 'error': - return DiagnosticSeverity.Error; - case 'warn': - return DiagnosticSeverity.Warning; - case 'info': - return DiagnosticSeverity.Information; - case 'verbose': - return DiagnosticSeverity.Hint; - default: - throw new Error('Unknown severity'); - } -} - -export function normalizeFilePath( - filePath: FilePath, - projectRoot: FilePath, -): FilePath { - return path.isAbsolute(filePath) - ? filePath - : path.join(projectRoot, filePath); -} - -// export function isInRange(loc: SourceLocation, position: Position): boolean { -// let pos = {line: position.line + 1, column: position.character + 1}; - -// if (pos.line < loc.start.line || loc.end.line < pos.line) { -// return false; -// } -// if (pos.line === loc.start.line) { -// return loc.start.column <= pos.column; -// } -// if (pos.line === loc.end.line - 1) { -// return pos.column < loc.start.column; -// } -// return true; -// } - -// /** This range is used when refering to a whole file and not a specific range. */ -// export const RANGE_DUMMY: Range = { -// start: {line: 0, character: 0}, -// end: {line: 0, character: 0}, -// }; - -// Copied over from vscode-languageserver to prevent the runtime dependency -export const DiagnosticTag = { - /** - * Unused or unnecessary code. - * - * Clients are allowed to render diagnostics with this tag faded out instead of having - * an error squiggle. - */ - // $FlowFixMe - Unnecessary: (1: ODiagnosticTag), - /** - * Deprecated or obsolete code. - * - * Clients are allowed to rendered diagnostics with this tag strike through. - */ - // $FlowFixMe - Deprecated: (2: ODiagnosticTag), -}; -export const DiagnosticSeverity = { - /** - * Reports an error. - */ - // $FlowFixMe - Error: (1: ODiagnosticSeverity), - /** - * Reports a warning. - */ - // $FlowFixMe - Warning: (2: ODiagnosticSeverity), - /** - * Reports an information. - */ - // $FlowFixMe - Information: (3: ODiagnosticSeverity), - /** - * Reports a hint. - */ - // $FlowFixMe - Hint: (4: ODiagnosticSeverity), -}; diff --git a/packages/reporters/lsp-reporter/src/utils.ts b/packages/reporters/lsp-reporter/src/utils.ts new file mode 100644 index 000000000..075a568e5 --- /dev/null +++ b/packages/reporters/lsp-reporter/src/utils.ts @@ -0,0 +1,95 @@ +import type {DiagnosticLogEvent, FilePath} from '@atlaspack/types'; +// @ts-expect-error - TS2724 - '"vscode-languageserver"' has no exported member named 'ODiagnosticSeverity'. Did you mean 'DiagnosticSeverity'? +import type {ODiagnosticSeverity} from 'vscode-languageserver'; + +import path from 'path'; + +export type ParcelSeverity = unknown; + +export function parcelSeverityToLspSeverity( + parcelSeverity: ParcelSeverity, +): ODiagnosticSeverity { + switch (parcelSeverity) { + case 'error': + return DiagnosticSeverity.Error; + case 'warn': + return DiagnosticSeverity.Warning; + case 'info': + return DiagnosticSeverity.Information; + case 'verbose': + return DiagnosticSeverity.Hint; + default: + throw new Error('Unknown severity'); + } +} + +export function normalizeFilePath( + filePath: FilePath, + projectRoot: FilePath, +): FilePath { + return path.isAbsolute(filePath) + ? filePath + : path.join(projectRoot, filePath); +} + +// export function isInRange(loc: SourceLocation, position: Position): boolean { +// let pos = {line: position.line + 1, column: position.character + 1}; + +// if (pos.line < loc.start.line || loc.end.line < pos.line) { +// return false; +// } +// if (pos.line === loc.start.line) { +// return loc.start.column <= pos.column; +// } +// if (pos.line === loc.end.line - 1) { +// return pos.column < loc.start.column; +// } +// return true; +// } + +// /** This range is used when refering to a whole file and not a specific range. */ +// export const RANGE_DUMMY: Range = { +// start: {line: 0, character: 0}, +// end: {line: 0, character: 0}, +// }; + +// Copied over from vscode-languageserver to prevent the runtime dependency +export const DiagnosticTag = { + /** + * Unused or unnecessary code. + * + * Clients are allowed to render diagnostics with this tag faded out instead of having + * an error squiggle. + */ + // $FlowFixMe + Unnecessary: 1 as ODiagnosticTag, + /** + * Deprecated or obsolete code. + * + * Clients are allowed to rendered diagnostics with this tag strike through. + */ + // $FlowFixMe + Deprecated: 2 as ODiagnosticTag, +} as const; +export const DiagnosticSeverity = { + /** + * Reports an error. + */ + // $FlowFixMe + Error: 1 as ODiagnosticSeverity, + /** + * Reports a warning. + */ + // $FlowFixMe + Warning: 2 as ODiagnosticSeverity, + /** + * Reports an information. + */ + // $FlowFixMe + Information: 3 as ODiagnosticSeverity, + /** + * Reports a hint. + */ + // $FlowFixMe + Hint: 4 as ODiagnosticSeverity, +} as const; diff --git a/packages/reporters/sourcemap-visualiser/package.json b/packages/reporters/sourcemap-visualiser/package.json index abaf4c62e..e43287b22 100644 --- a/packages/reporters/sourcemap-visualiser/package.json +++ b/packages/reporters/sourcemap-visualiser/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/SourceMapVisualiser.js", - "source": "src/SourceMapVisualiser.js", + "types": "src/SourceMapVisualiser.ts", + "source": "src/SourceMapVisualiser.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.js b/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.js deleted file mode 100644 index 1b5af2d10..000000000 --- a/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.js +++ /dev/null @@ -1,69 +0,0 @@ -// @flow -import path from 'path'; -import nullthrows from 'nullthrows'; -import {Reporter} from '@atlaspack/plugin'; -import {relativePath} from '@atlaspack/utils'; - -export default (new Reporter({ - async report({event, options, logger}) { - if (event.type === 'buildSuccess') { - let bundles = []; - for (let bundle of event.bundleGraph.getBundles()) { - let p = bundle.filePath; - if (p) { - let mapFilePath = p + '.map'; - let hasMap = await options.outputFS.exists(mapFilePath); - if (hasMap) { - let map = JSON.parse( - await options.outputFS.readFile(mapFilePath, 'utf-8'), - ); - - let mappedSources = await Promise.all( - map.sources.map(async (sourceName, index) => { - let sourceContent = map.sourcesContent?.[index]; - if (sourceContent != null) { - try { - sourceContent = await options.inputFS.readFile( - path.resolve(options.projectRoot, sourceName), - 'utf-8', - ); - } catch (e) { - logger.warn({ - message: `Error while loading content of ${sourceName}, ${e.message}`, - }); - } - } - - return { - name: sourceName, - content: sourceContent ?? '', - }; - }), - ); - - let fileName = relativePath(options.projectRoot, p); - bundles.push({ - name: fileName, - mappings: map.mappings, - names: map.names, - sources: mappedSources, - content: await options.outputFS.readFile( - nullthrows(bundle.filePath), - 'utf-8', - ), - }); - } - } - } - - await options.outputFS.writeFile( - path.join(options.projectRoot, 'sourcemap-info.json'), - JSON.stringify(bundles), - ); - - logger.log({ - message: `Goto https://sourcemap-visualiser.now.sh/ and upload the generated sourcemap-info.json file to visualise and debug the sourcemaps.`, - }); - } - }, -}): Reporter); diff --git a/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.ts b/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.ts new file mode 100644 index 000000000..58fdba4b1 --- /dev/null +++ b/packages/reporters/sourcemap-visualiser/src/SourceMapVisualiser.ts @@ -0,0 +1,76 @@ +import path from 'path'; +import nullthrows from 'nullthrows'; +import {Reporter} from '@atlaspack/plugin'; +import {relativePath} from '@atlaspack/utils'; + +export default new Reporter({ + async report({event, options, logger}) { + if (event.type === 'buildSuccess') { + let bundles: Array<{ + content: string; + mappings: any; + name: FilePath; + names: any; + sources: never; + }> = []; + for (let bundle of event.bundleGraph.getBundles()) { + let p = bundle.filePath; + if (p) { + let mapFilePath = p + '.map'; + let hasMap = await options.outputFS.exists(mapFilePath); + if (hasMap) { + let map = JSON.parse( + await options.outputFS.readFile(mapFilePath, 'utf-8'), + ); + + let mappedSources = await Promise.all( + // @ts-expect-error - TS7006 - Parameter 'sourceName' implicitly has an 'any' type. | TS7006 - Parameter 'index' implicitly has an 'any' type. + map.sources.map(async (sourceName, index) => { + let sourceContent = map.sourcesContent?.[index]; + if (sourceContent != null) { + try { + sourceContent = await options.inputFS.readFile( + path.resolve(options.projectRoot, sourceName), + 'utf-8', + ); + } catch (e: any) { + logger.warn({ + message: `Error while loading content of ${sourceName}, ${e.message}`, + }); + } + } + + return { + name: sourceName, + content: sourceContent ?? '', + }; + }), + ); + + let fileName = relativePath(options.projectRoot, p); + bundles.push({ + name: fileName, + mappings: map.mappings, + names: map.names, + // @ts-expect-error - TS2322 - Type 'any[]' is not assignable to type 'never'. + sources: mappedSources, + content: await options.outputFS.readFile( + nullthrows(bundle.filePath), + 'utf-8', + ), + }); + } + } + } + + await options.outputFS.writeFile( + path.join(options.projectRoot, 'sourcemap-info.json'), + JSON.stringify(bundles), + ); + + logger.log({ + message: `Goto https://sourcemap-visualiser.now.sh/ and upload the generated sourcemap-info.json file to visualise and debug the sourcemaps.`, + }); + } + }, +}) as Reporter; diff --git a/packages/reporters/tracer/package.json b/packages/reporters/tracer/package.json index e52e38941..50373d391 100644 --- a/packages/reporters/tracer/package.json +++ b/packages/reporters/tracer/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/TracerReporter.js", - "source": "src/TracerReporter.js", + "types": "src/TracerReporter.ts", + "source": "src/TracerReporter.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/reporters/tracer/src/TracerReporter.js b/packages/reporters/tracer/src/TracerReporter.js deleted file mode 100644 index e4824cf9e..000000000 --- a/packages/reporters/tracer/src/TracerReporter.js +++ /dev/null @@ -1,87 +0,0 @@ -// @flow -import invariant from 'assert'; -import nullthrows from 'nullthrows'; -import path from 'path'; -import {Reporter} from '@atlaspack/plugin'; -import {Tracer} from 'chrome-trace-event'; - -// We need to maintain some state here to ensure we write to the same output, there should only be one -// instance of this reporter (this gets asserted below) -let tracer; -let writeStream = null; - -function millisecondsToMicroseconds(milliseconds: number) { - return Math.floor(milliseconds * 1000); -} - -// TODO: extract this to utils as it's also used in packages/core/workers/src/WorkerFarm.js -function getTimeId() { - let now = new Date(); - return ( - String(now.getFullYear()) + - String(now.getMonth() + 1).padStart(2, '0') + - String(now.getDate()).padStart(2, '0') + - '-' + - String(now.getHours()).padStart(2, '0') + - String(now.getMinutes()).padStart(2, '0') + - String(now.getSeconds()).padStart(2, '0') - ); -} - -export default (new Reporter({ - report({event, options, logger}) { - let filename; - let filePath; - switch (event.type) { - case 'buildStart': - invariant(tracer == null, 'Tracer multiple initialisation'); - tracer = new Tracer(); - filename = `parcel-trace-${getTimeId()}.json`; - filePath = path.join(options.projectRoot, filename); - invariant( - writeStream == null, - 'Trace write stream multiple initialisation', - ); - logger.info({ - message: `Writing trace to ${filename}. See https://parceljs.org/features/profiling/#analysing-traces for more information on working with traces.`, - }); - writeStream = options.outputFS.createWriteStream(filePath); - nullthrows(tracer).pipe(nullthrows(writeStream)); - break; - case 'trace': - // Due to potential race conditions at the end of the build, we ignore any trace events that occur - // after we've closed the write stream. - if (tracer === null) return; - - tracer.completeEvent({ - name: event.name, - cat: event.categories, - args: event.args, - ts: millisecondsToMicroseconds(event.ts), - dur: millisecondsToMicroseconds(event.duration), - tid: event.tid, - pid: event.pid, - }); - break; - case 'buildSuccess': - case 'buildFailure': - nullthrows(tracer).flush(); - tracer = null; - // We explicitly trigger `end` on the writeStream for the trace, then we need to wait for - // the `close` event before resolving the promise this report function returns to ensure - // that the file has been properly closed and moved from it's temp location before Parcel - // shuts down. - return new Promise((resolve, reject) => { - nullthrows(writeStream).once('close', err => { - writeStream = null; - if (err) { - reject(err); - } else { - resolve(); - } - }); - nullthrows(writeStream).end(); - }); - } - }, -}): Reporter); diff --git a/packages/reporters/tracer/src/TracerReporter.ts b/packages/reporters/tracer/src/TracerReporter.ts new file mode 100644 index 000000000..92e7d339f --- /dev/null +++ b/packages/reporters/tracer/src/TracerReporter.ts @@ -0,0 +1,101 @@ +import invariant from 'assert'; +import nullthrows from 'nullthrows'; +import path from 'path'; +import {Reporter} from '@atlaspack/plugin'; +import {Tracer} from 'chrome-trace-event'; + +// We need to maintain some state here to ensure we write to the same output, there should only be one +// instance of this reporter (this gets asserted below) +// @ts-expect-error - TS7034 - Variable 'tracer' implicitly has type 'any' in some locations where its type cannot be determined. +let tracer; +// @ts-expect-error - TS7034 - Variable 'writeStream' implicitly has type 'any' in some locations where its type cannot be determined. +let writeStream = null; + +function millisecondsToMicroseconds(milliseconds: number) { + return Math.floor(milliseconds * 1000); +} + +// TODO: extract this to utils as it's also used in packages/core/workers/src/WorkerFarm.js +function getTimeId() { + let now = new Date(); + return ( + String(now.getFullYear()) + + String(now.getMonth() + 1).padStart(2, '0') + + String(now.getDate()).padStart(2, '0') + + '-' + + String(now.getHours()).padStart(2, '0') + + String(now.getMinutes()).padStart(2, '0') + + String(now.getSeconds()).padStart(2, '0') + ); +} + +export default new Reporter({ + report({event, options, logger}) { + let filename; + let filePath; + switch (event.type) { + case 'buildStart': + // @ts-expect-error - TS7005 - Variable 'tracer' implicitly has an 'any' type. + invariant(tracer == null, 'Tracer multiple initialisation'); + tracer = new Tracer(); + filename = `parcel-trace-${getTimeId()}.json`; + filePath = path.join(options.projectRoot, filename); + invariant( + // @ts-expect-error - TS7005 - Variable 'writeStream' implicitly has an 'any' type. + writeStream == null, + 'Trace write stream multiple initialisation', + ); + logger.info({ + message: `Writing trace to ${filename}. See https://parceljs.org/features/profiling/#analysing-traces for more information on working with traces.`, + }); + writeStream = options.outputFS.createWriteStream(filePath); + nullthrows(tracer).pipe(nullthrows(writeStream)); + break; + case 'trace': + // Due to potential race conditions at the end of the build, we ignore any trace events that occur + // after we've closed the write stream. + // @ts-expect-error - TS7005 - Variable 'tracer' implicitly has an 'any' type. + if (tracer === null) return; + + // @ts-expect-error - TS7005 - Variable 'tracer' implicitly has an 'any' type. + tracer.completeEvent({ + name: event.name, + cat: event.categories, + args: event.args, + ts: millisecondsToMicroseconds(event.ts), + dur: millisecondsToMicroseconds(event.duration), + tid: event.tid, + pid: event.pid, + }); + break; + case 'buildSuccess': + case 'buildFailure': + // @ts-expect-error - TS7005 - Variable 'tracer' implicitly has an 'any' type. + nullthrows(tracer).flush(); + tracer = null; + // We explicitly trigger `end` on the writeStream for the trace, then we need to wait for + // the `close` event before resolving the promise this report function returns to ensure + // that the file has been properly closed and moved from it's temp location before Parcel + // shuts down. + return new Promise( + ( + resolve: (result: Promise | undefined) => void, + reject: (error?: any) => void, + ) => { + // @ts-expect-error - TS7005 - Variable 'writeStream' implicitly has an 'any' type. | TS7006 - Parameter 'err' implicitly has an 'any' type. + nullthrows(writeStream).once('close', (err) => { + writeStream = null; + if (err) { + reject(err); + } else { + // @ts-expect-error - TS2794 - Expected 1 arguments, but got 0. Did you forget to include 'void' in your type argument to 'Promise'? + resolve(); + } + }); + // @ts-expect-error - TS7005 - Variable 'writeStream' implicitly has an 'any' type. + nullthrows(writeStream).end(); + }, + ); + } + }, +}) as Reporter; diff --git a/packages/resolvers/default/package.json b/packages/resolvers/default/package.json index 5d1ac7f07..5ecad4398 100644 --- a/packages/resolvers/default/package.json +++ b/packages/resolvers/default/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/DefaultResolver.js", - "source": "src/DefaultResolver.js", + "types": "src/DefaultResolver.ts", + "source": "src/DefaultResolver.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/resolvers/default/src/DefaultResolver.js b/packages/resolvers/default/src/DefaultResolver.js deleted file mode 100644 index 86574538b..000000000 --- a/packages/resolvers/default/src/DefaultResolver.js +++ /dev/null @@ -1,44 +0,0 @@ -// @flow - -import {Resolver} from '@atlaspack/plugin'; -import NodeResolver from '@atlaspack/node-resolver-core'; - -// Throw user friendly errors on special webpack loader syntax -// ex. `imports-loader?$=jquery!./example.js` -const WEBPACK_IMPORT_REGEX = /^\w+-loader(?:\?\S*)?!/; - -export default (new Resolver({ - async loadConfig({config, options, logger}) { - let conf = await config.getConfig([], { - packageKey: '@atlaspack/resolver-default', - }); - - return new NodeResolver({ - fs: options.inputFS, - projectRoot: options.projectRoot, - packageManager: options.packageManager, - shouldAutoInstall: options.shouldAutoInstall, - mode: options.mode, - logger, - packageExports: conf?.contents?.packageExports ?? false, - }); - }, - resolve({dependency, specifier, config: resolver}) { - if (WEBPACK_IMPORT_REGEX.test(dependency.specifier)) { - throw new Error( - `The import path: ${dependency.specifier} is using webpack specific loader import syntax, which isn't supported by Parcel.`, - ); - } - - return resolver.resolve({ - filename: specifier, - specifierType: dependency.specifierType, - range: dependency.range, - parent: dependency.resolveFrom, - env: dependency.env, - sourcePath: dependency.sourcePath, - loc: dependency.loc, - packageConditions: dependency.packageConditions, - }); - }, -}): Resolver); diff --git a/packages/resolvers/default/src/DefaultResolver.ts b/packages/resolvers/default/src/DefaultResolver.ts new file mode 100644 index 000000000..f85a38200 --- /dev/null +++ b/packages/resolvers/default/src/DefaultResolver.ts @@ -0,0 +1,44 @@ +import {Resolver} from '@atlaspack/plugin'; +import NodeResolver from '@atlaspack/node-resolver-core'; + +// Throw user friendly errors on special webpack loader syntax +// ex. `imports-loader?$=jquery!./example.js` +const WEBPACK_IMPORT_REGEX = /^\w+-loader(?:\?\S*)?!/; + +export default new Resolver({ + async loadConfig({config, options, logger}) { + let conf = await config.getConfig([], { + packageKey: '@atlaspack/resolver-default', + }); + + return new NodeResolver({ + fs: options.inputFS, + projectRoot: options.projectRoot, + packageManager: options.packageManager, + shouldAutoInstall: options.shouldAutoInstall, + mode: options.mode, + logger, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + packageExports: conf?.contents?.packageExports ?? false, + }); + }, + resolve({dependency, specifier, config: resolver}) { + if (WEBPACK_IMPORT_REGEX.test(dependency.specifier)) { + throw new Error( + `The import path: ${dependency.specifier} is using webpack specific loader import syntax, which isn't supported by Parcel.`, + ); + } + + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + return resolver.resolve({ + filename: specifier, + specifierType: dependency.specifierType, + range: dependency.range, + parent: dependency.resolveFrom, + env: dependency.env, + sourcePath: dependency.sourcePath, + loc: dependency.loc, + packageConditions: dependency.packageConditions, + }); + }, +}) as Resolver; diff --git a/packages/resolvers/glob/package.json b/packages/resolvers/glob/package.json index 398f7cda4..69f35f55a 100644 --- a/packages/resolvers/glob/package.json +++ b/packages/resolvers/glob/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/GlobResolver.js", - "source": "src/GlobResolver.js", + "types": "src/GlobResolver.ts", + "source": "src/GlobResolver.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/resolvers/glob/src/GlobResolver.js b/packages/resolvers/glob/src/GlobResolver.js deleted file mode 100644 index 5b90481de..000000000 --- a/packages/resolvers/glob/src/GlobResolver.js +++ /dev/null @@ -1,274 +0,0 @@ -// @flow -import {Resolver} from '@atlaspack/plugin'; -import { - isGlob, - glob, - globToRegex, - relativePath, - normalizeSeparators, -} from '@atlaspack/utils'; -import path from 'path'; -import nullthrows from 'nullthrows'; -import ThrowableDiagnostic, { - convertSourceLocationToHighlight, -} from '@atlaspack/diagnostic'; -import NodeResolver from '@atlaspack/node-resolver-core'; -import invariant from 'assert'; - -function errorToThrowableDiagnostic(error, dependency): ThrowableDiagnostic { - return new ThrowableDiagnostic({ - diagnostic: { - message: error, - codeFrames: dependency.loc - ? [ - { - codeHighlights: [ - convertSourceLocationToHighlight(dependency.loc), - ], - }, - ] - : undefined, - }, - }); -} - -export default (new Resolver({ - async resolve({dependency, options, specifier, pipeline, logger}) { - if (!isGlob(specifier)) { - return; - } - - let sourceAssetType = nullthrows(dependency.sourceAssetType); - let sourceFile = nullthrows( - dependency.resolveFrom ?? dependency.sourcePath, - ); - - let error; - if (sourceAssetType !== 'js' && sourceAssetType !== 'css') { - error = `Glob imports are not supported in ${sourceAssetType} files.`; - } else if ( - dependency.specifierType === 'url' && - !dependency.meta?.isCSSImport - ) { - error = 'Glob imports are not supported in URL dependencies.'; - } - - if (error) { - throw errorToThrowableDiagnostic(error, dependency); - } - - let invalidateOnFileCreate = []; - let invalidateOnFileChange = new Set(); - - switch (specifier[0]) { - // Path specifier - case '.': { - specifier = path.resolve(path.dirname(sourceFile), specifier); - break; - } - - // Absolute path. Make the glob relative to the project root. - case '/': { - specifier = path.resolve(options.projectRoot, specifier.slice(1)); - break; - } - - // Tilde path. Package relative. Resolve relative to nearest node_modules - // directory, the nearest directory with package.json or the project - // root - whichever comes first. - case '~': { - let dir = path.dirname(sourceFile); - let pkgPath = nullthrows( - options.inputFS.findAncestorFile( - ['package.json'], - dir, - options.projectRoot, - ), - ); - specifier = path.resolve(path.dirname(pkgPath), specifier.slice(2)); - break; - } - - // Support package-ish specifiers like: - // foo (node_module) - // @foo/bar (scoped node_module) - // - // First we resolve the initial portion using NodeResolver, then we tack - // on the remaining glob. - default: { - // Globs are not paths - so they always use / (see https://github.com/micromatch/micromatch#backslashes) - let splitOn = specifier.indexOf('/'); - if (specifier[0] === '@') { - splitOn = specifier.indexOf('/', splitOn + 1); - } - - // Since we've already asserted earlier that there is a glob present, it shouldn't be - // possible for there to be only a package here without any other path parts (e.g. `import('pkg')`) - invariant(splitOn !== -1); - - let pkg = specifier.substring(0, splitOn); - let rest = specifier.substring(splitOn + 1); - - // This initialisation code is copied from the DefaultResolver - const resolver = new NodeResolver({ - fs: options.inputFS, - projectRoot: options.projectRoot, - packageManager: options.shouldAutoInstall - ? options.packageManager - : undefined, - mode: options.mode, - logger, - }); - - let result; - try { - result = await resolver.resolve({ - filename: pkg + '/package.json', - parent: dependency.resolveFrom, - specifierType: 'esm', - env: dependency.env, - sourcePath: dependency.sourcePath, - }); - } catch (err) { - if (err instanceof ThrowableDiagnostic) { - // Return instead of throwing so we can provide invalidations. - return { - diagnostics: err.diagnostics, - invalidateOnFileCreate, - invalidateOnFileChange: [...invalidateOnFileChange], - }; - } else { - throw err; - } - } - - if (!result || !result.filePath) { - throw errorToThrowableDiagnostic( - `Unable to resolve ${pkg} from ${sourceFile} when resolving specifier ${specifier}`, - dependency, - ); - } - - specifier = path.resolve(path.dirname(result.filePath), rest); - if (result.invalidateOnFileChange) { - for (let f of result.invalidateOnFileChange) { - invalidateOnFileChange.add(f); - } - } - if (result.invalidateOnFileCreate) { - invalidateOnFileCreate.push(...result.invalidateOnFileCreate); - } - } - } - - let normalized = normalizeSeparators(specifier); - let files = await glob(normalized, options.inputFS, { - onlyFiles: true, - }); - - let dir = path.dirname(sourceFile); - let results = files.map(file => { - let relative = relativePath(dir, file); - if (pipeline) { - relative = `${pipeline}:${relative}`; - } - - return [file, relative]; - }); - - let code = ''; - if (sourceAssetType === 'js') { - let re = globToRegex(normalized, {capture: true}); - let matches = {}; - for (let [file, relative] of results) { - let match = file.match(re); - if (!match) continue; - let parts = match - .slice(1) - .filter(Boolean) - .reduce((a, p) => a.concat(p.split('/')), []); - set(matches, parts, relative); - } - - let {value, imports} = generate(matches, dependency.priority === 'lazy'); - code = imports + 'module.exports = ' + value; - } else if (sourceAssetType === 'css') { - for (let [, relative] of results) { - code += `@import "${relative}";\n`; - } - } - - invalidateOnFileCreate.push({glob: normalized}); - - return { - filePath: path.join( - dir, - path.basename(specifier, path.extname(specifier)) + - '.' + - sourceAssetType, - ), - code, - invalidateOnFileCreate, - invalidateOnFileChange: [...invalidateOnFileChange], - pipeline: null, - priority: 'sync', - }; - }, -}): Resolver); - -function set(obj, path, value) { - for (let i = 0; i < path.length - 1; i++) { - let part = path[i]; - - if (obj[part] == null) { - obj[part] = {}; - } - - obj = obj[part]; - } - - obj[path[path.length - 1]] = value; -} - -function generate(matches, isAsync, indent = '', count = 0) { - if (typeof matches === 'string') { - if (isAsync) { - return { - imports: '', - value: `() => import(${JSON.stringify(matches)})`, - count, - }; - } - - let key = `_temp${count++}`; - return { - imports: `const ${key} = require(${JSON.stringify(matches)});`, - value: key, - count, - }; - } - - let imports = ''; - let res = indent + '{'; - - let first = true; - for (let key in matches) { - if (!first) { - res += ','; - } - - let { - imports: i, - value, - count: c, - } = generate(matches[key], isAsync, indent + ' ', count); - imports += `${i}\n`; - count = c; - - res += `\n${indent} ${JSON.stringify(key)}: ${value}`; - first = false; - } - - res += '\n' + indent + '}'; - return {imports, value: res, count}; -} diff --git a/packages/resolvers/glob/src/GlobResolver.ts b/packages/resolvers/glob/src/GlobResolver.ts new file mode 100644 index 000000000..8b003ad67 --- /dev/null +++ b/packages/resolvers/glob/src/GlobResolver.ts @@ -0,0 +1,283 @@ +import {Resolver} from '@atlaspack/plugin'; +import { + isGlob, + glob, + globToRegex, + relativePath, + normalizeSeparators, +} from '@atlaspack/utils'; +import path from 'path'; +import nullthrows from 'nullthrows'; +import ThrowableDiagnostic, { + convertSourceLocationToHighlight, +} from '@atlaspack/diagnostic'; +import NodeResolver from '@atlaspack/node-resolver-core'; +import invariant from 'assert'; + +function errorToThrowableDiagnostic( + error: string, + dependency: Dependency, +): ThrowableDiagnostic { + return new ThrowableDiagnostic({ + diagnostic: { + message: error, + codeFrames: dependency.loc + ? [ + { + codeHighlights: [ + convertSourceLocationToHighlight(dependency.loc), + ], + }, + ] + : undefined, + }, + }); +} + +export default new Resolver({ + // @ts-expect-error - TS2322 - Type '({ dependency, options, specifier, pipeline, logger }: { dependency: Dependency; options: PluginOptions; logger: PluginLogger; tracer: PluginTracer; specifier: string; pipeline: string | ... 1 more ... | undefined; config: unknown; }) => Promise<...>' is not assignable to type '(arg1: { dependency: Dependency; options: PluginOptions; logger: PluginLogger; tracer: PluginTracer; specifier: string; pipeline: string | ... 1 more ... | undefined; config: unknown; }) => Async<...>'. + async resolve({dependency, options, specifier, pipeline, logger}) { + if (!isGlob(specifier)) { + return; + } + + let sourceAssetType = nullthrows(dependency.sourceAssetType); + let sourceFile = nullthrows( + dependency.resolveFrom ?? dependency.sourcePath, + ); + + let error; + if (sourceAssetType !== 'js' && sourceAssetType !== 'css') { + error = `Glob imports are not supported in ${sourceAssetType} files.`; + } else if ( + dependency.specifierType === 'url' && + !dependency.meta?.isCSSImport + ) { + error = 'Glob imports are not supported in URL dependencies.'; + } + + if (error) { + throw errorToThrowableDiagnostic(error, dependency); + } + + let invalidateOnFileCreate: Array = []; + let invalidateOnFileChange = new Set(); + + switch (specifier[0]) { + // Path specifier + case '.': { + specifier = path.resolve(path.dirname(sourceFile), specifier); + break; + } + + // Absolute path. Make the glob relative to the project root. + case '/': { + specifier = path.resolve(options.projectRoot, specifier.slice(1)); + break; + } + + // Tilde path. Package relative. Resolve relative to nearest node_modules + // directory, the nearest directory with package.json or the project + // root - whichever comes first. + case '~': { + let dir = path.dirname(sourceFile); + let pkgPath = nullthrows( + options.inputFS.findAncestorFile( + ['package.json'], + dir, + options.projectRoot, + ), + ); + specifier = path.resolve(path.dirname(pkgPath), specifier.slice(2)); + break; + } + + // Support package-ish specifiers like: + // foo (node_module) + // @foo/bar (scoped node_module) + // + // First we resolve the initial portion using NodeResolver, then we tack + // on the remaining glob. + default: { + // Globs are not paths - so they always use / (see https://github.com/micromatch/micromatch#backslashes) + let splitOn = specifier.indexOf('/'); + if (specifier[0] === '@') { + splitOn = specifier.indexOf('/', splitOn + 1); + } + + // Since we've already asserted earlier that there is a glob present, it shouldn't be + // possible for there to be only a package here without any other path parts (e.g. `import('pkg')`) + invariant(splitOn !== -1); + + let pkg = specifier.substring(0, splitOn); + let rest = specifier.substring(splitOn + 1); + + // This initialisation code is copied from the DefaultResolver + const resolver = new NodeResolver({ + fs: options.inputFS, + projectRoot: options.projectRoot, + packageManager: options.shouldAutoInstall + ? options.packageManager + : undefined, + mode: options.mode, + logger, + }); + + let result; + try { + result = await resolver.resolve({ + filename: pkg + '/package.json', + parent: dependency.resolveFrom, + specifierType: 'esm', + env: dependency.env, + sourcePath: dependency.sourcePath, + }); + } catch (err: any) { + if (err instanceof ThrowableDiagnostic) { + // Return instead of throwing so we can provide invalidations. + return { + diagnostics: err.diagnostics, + invalidateOnFileCreate, + invalidateOnFileChange: [...invalidateOnFileChange], + }; + } else { + throw err; + } + } + + if (!result || !result.filePath) { + throw errorToThrowableDiagnostic( + `Unable to resolve ${pkg} from ${sourceFile} when resolving specifier ${specifier}`, + dependency, + ); + } + + specifier = path.resolve(path.dirname(result.filePath), rest); + if (result.invalidateOnFileChange) { + for (let f of result.invalidateOnFileChange) { + invalidateOnFileChange.add(f); + } + } + if (result.invalidateOnFileCreate) { + invalidateOnFileCreate.push(...result.invalidateOnFileCreate); + } + } + } + + let normalized = normalizeSeparators(specifier); + let files = await glob(normalized, options.inputFS, { + onlyFiles: true, + }); + + let dir = path.dirname(sourceFile); + let results = files.map((file) => { + let relative = relativePath(dir, file); + if (pipeline) { + relative = `${pipeline}:${relative}`; + } + + return [file, relative]; + }); + + let code = ''; + if (sourceAssetType === 'js') { + let re = globToRegex(normalized, {capture: true}); + let matches: Record = {}; + for (let [file, relative] of results) { + let match = file.match(re); + if (!match) continue; + let parts = match + .slice(1) + .filter(Boolean) + .reduce>((a, p) => a.concat(p.split('/')), []); + set(matches, parts, relative); + } + + let {value, imports} = generate(matches, dependency.priority === 'lazy'); + code = imports + 'module.exports = ' + value; + } else if (sourceAssetType === 'css') { + for (let [, relative] of results) { + code += `@import "${relative}";\n`; + } + } + + invalidateOnFileCreate.push({glob: normalized}); + + return { + filePath: path.join( + dir, + path.basename(specifier, path.extname(specifier)) + + '.' + + sourceAssetType, + ), + code, + invalidateOnFileCreate, + invalidateOnFileChange: [...invalidateOnFileChange], + pipeline: null, + priority: 'sync', + }; + }, +}) as Resolver; + +function set( + // @ts-expect-error - TS7006 - Parameter 'obj' implicitly has an 'any' type. + obj, + path: Array | Array, + value: FilePath | string, +) { + for (let i = 0; i < path.length - 1; i++) { + let part = path[i]; + + if (obj[part] == null) { + obj[part] = {}; + } + + obj = obj[part]; + } + + obj[path[path.length - 1]] = value; +} + +// @ts-expect-error - TS7006 - Parameter 'matches' implicitly has an 'any' type. +function generate(matches, isAsync: boolean, indent = '', count = 0) { + if (typeof matches === 'string') { + if (isAsync) { + return { + imports: '', + value: `() => import(${JSON.stringify(matches)})`, + count, + }; + } + + let key = `_temp${count++}`; + return { + imports: `const ${key} = require(${JSON.stringify(matches)});`, + value: key, + count, + }; + } + + let imports = ''; + let res = indent + '{'; + + let first = true; + for (let key in matches) { + if (!first) { + res += ','; + } + + let { + imports: i, + value, + count: c, + } = generate(matches[key], isAsync, indent + ' ', count); + imports += `${i}\n`; + count = c; + + res += `\n${indent} ${JSON.stringify(key)}: ${value}`; + first = false; + } + + res += '\n' + indent + '}'; + return {imports, value: res, count}; +} diff --git a/packages/resolvers/repl-runtimes/package.json b/packages/resolvers/repl-runtimes/package.json index df6d8cb44..d7e419d97 100644 --- a/packages/resolvers/repl-runtimes/package.json +++ b/packages/resolvers/repl-runtimes/package.json @@ -7,7 +7,7 @@ "type": "git", "url": "https://github.com/atlassian-labs/atlaspack.git" }, - "source": "src/REPLRuntimesResolver.js", + "source": "src/REPLRuntimesResolver.ts", "engines": { "parcel": "^2.11.0" }, diff --git a/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.js b/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.js deleted file mode 100644 index 450fadfca..000000000 --- a/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.js +++ /dev/null @@ -1,205 +0,0 @@ -// @flow - -import {Resolver} from '@atlaspack/plugin'; -import fs from 'fs'; -import path from 'path'; - -const FILES = new Map([ - [ - '/app/packages/runtimes/js/src/helpers/bundle-manifest.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/bundle-manifest.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/bundle-url.js', - fs.readFileSync( - __dirname + '/../../../../packages/runtimes/js/src/helpers/bundle-url.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/cacheLoader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/cacheLoader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/get-worker-url.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/get-worker-url.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/preload-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/preload-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/prefetch-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/prefetch-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/css-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/css-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/html-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/html-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/js-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/js-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/wasm-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/wasm-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/browser/import-polyfill.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/browser/import-polyfill.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/worker/js-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/worker/js-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/worker/wasm-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/worker/wasm-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/node/css-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/node/css-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/node/html-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/node/html-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/node/js-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/node/js-loader.js', - 'utf8', - ), - ], - [ - '/app/packages/runtimes/js/src/helpers/node/wasm-loader.js', - fs.readFileSync( - __dirname + - '/../../../../packages/runtimes/js/src/helpers/node/wasm-loader.js', - 'utf8', - ), - ], - [ - '@atlaspack/transformer-js/src/esmodule-helpers.js', - fs.readFileSync( - __dirname + - '/../../../../packages/transformers/js/src/esmodule-helpers.js', - 'utf8', - ), - ], - [ - '@atlaspack/transformer-react-refresh-wrap/src/helpers/helpers.js', - fs.readFileSync( - __dirname + - '/../../../../packages/transformers/react-refresh-wrap/src/helpers/helpers.js', - 'utf8', - ), - ], -]); - -const REACT_ERROR_OVERLAY = fs.readFileSync( - __dirname + '/../../../../node_modules/react-error-overlay/lib/index.js', - 'utf8', -); - -export default (new Resolver({ - resolve({dependency}) { - let {specifier, resolveFrom} = dependency; - - if (resolveFrom && resolveFrom.startsWith('/app/packages/')) { - if ( - specifier === 'react-error-overlay' && - resolveFrom.startsWith('/app/packages/runtimes/react-refresh/src/') - ) { - return { - filePath: `/react-error-overlay/lib/index.js`, - code: REACT_ERROR_OVERLAY, - }; - } - - let resolvedPath = specifier.startsWith('.') - ? path.resolve(path.dirname(resolveFrom), specifier) - : specifier; - - let filePath; - let code; - if (FILES.has(resolvedPath)) { - filePath = resolvedPath; - code = FILES.get(resolvedPath); - } else if (FILES.has(resolvedPath + '.js')) { - filePath = resolvedPath + '.js'; - code = FILES.get(resolvedPath + '.js'); - } - - if (filePath && code) { - return { - filePath: filePath.startsWith('@') - ? `/app/node_modules/${filePath}` - : filePath, - code, - }; - } - } - }, -}): Resolver); diff --git a/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.ts b/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.ts new file mode 100644 index 000000000..2b50bc8f2 --- /dev/null +++ b/packages/resolvers/repl-runtimes/src/REPLRuntimesResolver.ts @@ -0,0 +1,203 @@ +import {Resolver} from '@atlaspack/plugin'; +import fs from 'fs'; +import path from 'path'; + +const FILES = new Map([ + [ + '/app/packages/runtimes/js/src/helpers/bundle-manifest.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/bundle-manifest.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/bundle-url.js', + fs.readFileSync( + __dirname + '/../../../../packages/runtimes/js/src/helpers/bundle-url.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/cacheLoader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/cacheLoader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/get-worker-url.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/get-worker-url.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/preload-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/preload-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/prefetch-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/prefetch-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/css-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/css-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/html-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/html-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/js-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/js-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/wasm-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/wasm-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/browser/import-polyfill.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/browser/import-polyfill.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/worker/js-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/worker/js-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/worker/wasm-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/worker/wasm-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/node/css-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/node/css-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/node/html-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/node/html-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/node/js-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/node/js-loader.js', + 'utf8', + ), + ], + [ + '/app/packages/runtimes/js/src/helpers/node/wasm-loader.js', + fs.readFileSync( + __dirname + + '/../../../../packages/runtimes/js/src/helpers/node/wasm-loader.js', + 'utf8', + ), + ], + [ + '@atlaspack/transformer-js/src/esmodule-helpers.js', + fs.readFileSync( + __dirname + + '/../../../../packages/transformers/js/src/esmodule-helpers.js', + 'utf8', + ), + ], + [ + '@atlaspack/transformer-react-refresh-wrap/src/helpers/helpers.js', + fs.readFileSync( + __dirname + + '/../../../../packages/transformers/react-refresh-wrap/src/helpers/helpers.js', + 'utf8', + ), + ], +]); + +const REACT_ERROR_OVERLAY = fs.readFileSync( + __dirname + '/../../../../node_modules/react-error-overlay/lib/index.js', + 'utf8', +); + +export default new Resolver({ + resolve({dependency}) { + let {specifier, resolveFrom} = dependency; + + if (resolveFrom && resolveFrom.startsWith('/app/packages/')) { + if ( + specifier === 'react-error-overlay' && + resolveFrom.startsWith('/app/packages/runtimes/react-refresh/src/') + ) { + return { + filePath: `/react-error-overlay/lib/index.js`, + code: REACT_ERROR_OVERLAY, + }; + } + + let resolvedPath = specifier.startsWith('.') + ? path.resolve(path.dirname(resolveFrom), specifier) + : specifier; + + let filePath; + let code; + if (FILES.has(resolvedPath)) { + filePath = resolvedPath; + code = FILES.get(resolvedPath); + } else if (FILES.has(resolvedPath + '.js')) { + filePath = resolvedPath + '.js'; + code = FILES.get(resolvedPath + '.js'); + } + + if (filePath && code) { + return { + filePath: filePath.startsWith('@') + ? `/app/node_modules/${filePath}` + : filePath, + code, + }; + } + } + }, +}) as Resolver; diff --git a/packages/runtimes/hmr/package.json b/packages/runtimes/hmr/package.json index 8ca093e0b..2a5595009 100644 --- a/packages/runtimes/hmr/package.json +++ b/packages/runtimes/hmr/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/HMRRuntime.js", - "source": "src/HMRRuntime.js", + "types": "src/HMRRuntime.ts", + "source": "src/HMRRuntime.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/runtimes/hmr/src/HMRRuntime.js b/packages/runtimes/hmr/src/HMRRuntime.js deleted file mode 100644 index 30bdc1e2e..000000000 --- a/packages/runtimes/hmr/src/HMRRuntime.js +++ /dev/null @@ -1,64 +0,0 @@ -// @flow strict-local - -import {Runtime} from '@atlaspack/plugin'; -import fs from 'fs'; -import path from 'path'; - -// Without this, the hmr-runtime.js is transpiled with the React Refresh swc transform because it -// lives in `/app/packages/runtimes/...` and thus the `config` in the JSTransformer is actually the -// user's package.json, and hmr-runtime.js is transpiled as a JSX asset. -const FILENAME = - // $FlowFixMe - process.env.ATLASPACK_BUILD_REPL && process.browser - ? '/' + __filename - : __filename; - -const HMR_RUNTIME = fs.readFileSync( - path.join(__dirname, './loaders/hmr-runtime.js'), - 'utf8', -); - -export default (new Runtime({ - apply({bundle, options}) { - if ( - bundle.type !== 'js' || - !options.hmrOptions || - bundle.env.isLibrary || - bundle.env.isWorklet() || - bundle.env.sourceType === 'script' - ) { - return; - } - - const {host, port} = options.hmrOptions; - return { - filePath: FILENAME, - code: - `var HMR_HOST = ${JSON.stringify( - host != null && host !== '0.0.0.0' ? host : null, - )};` + - `var HMR_PORT = ${JSON.stringify( - port != null && - // Default to the HTTP port in the browser, only override - // in watch mode or if hmr port != serve port - (!options.serveOptions || options.serveOptions.port !== port) - ? port - : null, - )};` + - `var HMR_SECURE = ${JSON.stringify( - !!(options.serveOptions && options.serveOptions.https), - )};` + - `var HMR_ENV_HASH = "${bundle.env.id}";` + - `var HMR_USE_SSE = ${JSON.stringify( - // $FlowFixMe - !!(process.env.ATLASPACK_BUILD_REPL && process.browser), - )};` + - `module.bundle.HMR_BUNDLE_ID = ${JSON.stringify(bundle.id)};` + - HMR_RUNTIME, - isEntry: true, - env: { - sourceType: 'module', - }, - }; - }, -}): Runtime); diff --git a/packages/runtimes/hmr/src/HMRRuntime.ts b/packages/runtimes/hmr/src/HMRRuntime.ts new file mode 100644 index 000000000..7317d1027 --- /dev/null +++ b/packages/runtimes/hmr/src/HMRRuntime.ts @@ -0,0 +1,64 @@ +import {Runtime} from '@atlaspack/plugin'; +import fs from 'fs'; +import path from 'path'; + +// Without this, the hmr-runtime.js is transpiled with the React Refresh swc transform because it +// lives in `/app/packages/runtimes/...` and thus the `config` in the JSTransformer is actually the +// user's package.json, and hmr-runtime.js is transpiled as a JSX asset. +const FILENAME = + // $FlowFixMe + // @ts-expect-error - TS2339 - Property 'browser' does not exist on type 'Process'. + process.env.ATLASPACK_BUILD_REPL && process.browser + ? '/' + __filename + : __filename; + +const HMR_RUNTIME = fs.readFileSync( + path.join(__dirname, './loaders/hmr-runtime.js'), + 'utf8', +); + +export default new Runtime({ + apply({bundle, options}) { + if ( + bundle.type !== 'js' || + !options.hmrOptions || + bundle.env.isLibrary || + bundle.env.isWorklet() || + bundle.env.sourceType === 'script' + ) { + return; + } + + const {host, port} = options.hmrOptions; + return { + filePath: FILENAME, + code: + `var HMR_HOST = ${JSON.stringify( + host != null && host !== '0.0.0.0' ? host : null, + )};` + + `var HMR_PORT = ${JSON.stringify( + port != null && + // Default to the HTTP port in the browser, only override + // in watch mode or if hmr port != serve port + (!options.serveOptions || options.serveOptions.port !== port) + ? port + : null, + )};` + + `var HMR_SECURE = ${JSON.stringify( + !!(options.serveOptions && options.serveOptions.https), + )};` + + `var HMR_ENV_HASH = "${bundle.env.id}";` + + `var HMR_USE_SSE = ${JSON.stringify( + // $FlowFixMe + // @ts-expect-error - TS2339 - Property 'browser' does not exist on type 'Process'. + !!(process.env.ATLASPACK_BUILD_REPL && process.browser), + )};` + + `module.bundle.HMR_BUNDLE_ID = ${JSON.stringify(bundle.id)};` + + HMR_RUNTIME, + isEntry: true, + env: { + sourceType: 'module', + }, + }; + }, +}) as Runtime; diff --git a/packages/runtimes/hmr/src/loaders/hmr-runtime.js b/packages/runtimes/hmr/src/loaders/hmr-runtime.js index 76d40fc00..ee81eeaa8 100644 --- a/packages/runtimes/hmr/src/loaders/hmr-runtime.js +++ b/packages/runtimes/hmr/src/loaders/hmr-runtime.js @@ -1,4 +1,3 @@ -// @flow /* global HMR_HOST, HMR_PORT, HMR_ENV_HASH, HMR_SECURE, HMR_USE_SSE, chrome, browser, __parcel__import__, __parcel__importScripts__, ServiceWorkerGlobalScope */ /*:: @@ -133,7 +132,7 @@ if ((!parent || !parent.isParcelRequire) && typeof WebSocket !== 'undefined') { // $FlowFixMe ws.onmessage = async function (event /*: {data: string, ...} */) { - checkedAssets = ({} /*: {|[string]: boolean|} */); + checkedAssets = {} /*: {|[string]: boolean|} */; assetsToAccept = []; assetsToDispose = []; @@ -147,10 +146,12 @@ if ((!parent || !parent.isParcelRequire) && typeof WebSocket !== 'undefined') { removeErrorOverlay(); } - let assets = data.assets.filter(asset => asset.envHash === HMR_ENV_HASH); + let assets = data.assets.filter( + (asset) => asset.envHash === HMR_ENV_HASH, + ); // Handle HMR Update - let handled = assets.every(asset => { + let handled = assets.every((asset) => { return ( asset.type === 'css' || (asset.type === 'js' && @@ -172,7 +173,7 @@ if ((!parent || !parent.isParcelRequire) && typeof WebSocket !== 'undefined') { await hmrApplyUpdates(assets); // Dispose all old assets. - let processedAssets = ({} /*: {|[string]: boolean|} */); + let processedAssets = {}; /*: {|[string]: boolean|} */ for (let i = 0; i < assetsToDispose.length; i++) { let id = assetsToDispose[i][1]; @@ -270,7 +271,9 @@ ${frame.code}`;
${stack}
- ${diagnostic.hints.map(hint => '
💡 ' + hint + '
').join('')} + ${diagnostic.hints + .map((hint) => '
💡 ' + hint + '
') + .join('')}
${ diagnostic.documentation @@ -418,8 +421,8 @@ async function hmrApplyUpdates(assets) { // https://bugs.webkit.org/show_bug.cgi?id=137297 // This path is also taken if a CSP disallows eval. if (!supportsSourceURL) { - let promises = assets.map(asset => - hmrDownload(asset)?.catch(err => { + let promises = assets.map((asset) => + hmrDownload(asset)?.catch((err) => { // Web extension fix if ( extCtx && @@ -445,7 +448,7 @@ async function hmrApplyUpdates(assets) { delete global.parcelHotUpdate; if (scriptsToRemove) { - scriptsToRemove.forEach(script => { + scriptsToRemove.forEach((script) => { if (script) { document.head?.removeChild(script); } @@ -517,7 +520,7 @@ function hmrDelete(bundle, id) { delete bundle.cache[id]; // Now delete the orphans. - orphans.forEach(id => { + orphans.forEach((id) => { hmrDelete(module.bundle.root, id); }); } else if (bundle.parent) { diff --git a/packages/runtimes/js/package.json b/packages/runtimes/js/package.json index 5af1f6401..83f9309d2 100644 --- a/packages/runtimes/js/package.json +++ b/packages/runtimes/js/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/JSRuntime.js", - "source": "src/JSRuntime.js", + "types": "src/JSRuntime.ts", + "source": "src/JSRuntime.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/runtimes/js/src/JSRuntime.js b/packages/runtimes/js/src/JSRuntime.js deleted file mode 100644 index 526c74bb1..000000000 --- a/packages/runtimes/js/src/JSRuntime.js +++ /dev/null @@ -1,742 +0,0 @@ -// @flow strict-local - -import type { - BundleGraph, - BundleGroup, - Dependency, - Environment, - PluginOptions, - NamedBundle, - RuntimeAsset, -} from '@atlaspack/types'; - -import {Runtime} from '@atlaspack/plugin'; -import { - relativeBundlePath, - validateSchema, - type SchemaEntity, -} from '@atlaspack/utils'; -import {encodeJSONKeyComponent} from '@atlaspack/diagnostic'; -import path from 'path'; -import nullthrows from 'nullthrows'; - -// Used for as="" in preload/prefetch -const TYPE_TO_RESOURCE_PRIORITY = { - css: 'style', - js: 'script', -}; - -const BROWSER_PRELOAD_LOADER = './helpers/browser/preload-loader'; -const BROWSER_PREFETCH_LOADER = './helpers/browser/prefetch-loader'; - -const LOADERS = { - browser: { - css: './helpers/browser/css-loader', - html: './helpers/browser/html-loader', - js: './helpers/browser/js-loader', - wasm: './helpers/browser/wasm-loader', - IMPORT_POLYFILL: './helpers/browser/import-polyfill', - }, - worker: { - js: './helpers/worker/js-loader', - wasm: './helpers/worker/wasm-loader', - IMPORT_POLYFILL: false, - }, - node: { - css: './helpers/node/css-loader', - html: './helpers/node/html-loader', - js: './helpers/node/js-loader', - wasm: './helpers/node/wasm-loader', - IMPORT_POLYFILL: null, - }, -}; - -function getLoaders( - ctx: Environment, -): ?{[string]: string, IMPORT_POLYFILL: null | false | string, ...} { - if (ctx.isWorker()) return LOADERS.worker; - if (ctx.isBrowser()) return LOADERS.browser; - if (ctx.isNode()) return LOADERS.node; - return null; -} - -// This cache should be invalidated if new dependencies get added to the bundle without the bundle objects changing -// This can happen when we reuse the BundleGraph between subsequent builds -let bundleDependencies = new WeakMap< - NamedBundle, - {| - asyncDependencies: Array, - otherDependencies: Array, - |}, ->(); - -type JSRuntimeConfig = {| - splitManifestThreshold: number, -|}; - -let defaultConfig: JSRuntimeConfig = { - splitManifestThreshold: 100000, -}; - -const CONFIG_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - splitManifestThreshold: { - type: 'number', - }, - }, - additionalProperties: false, -}; - -export default (new Runtime({ - async loadConfig({config, options}): Promise { - let packageKey = '@atlaspack/runtime-js'; - let conf = await config.getConfig([], { - packageKey, - }); - - if (!conf) { - return defaultConfig; - } - validateSchema.diagnostic( - CONFIG_SCHEMA, - { - data: conf?.contents, - source: await options.inputFS.readFile(conf.filePath, 'utf8'), - filePath: conf.filePath, - prependKey: `/${encodeJSONKeyComponent(packageKey)}`, - }, - packageKey, - `Invalid config for ${packageKey}`, - ); - - return { - ...defaultConfig, - ...conf?.contents, - }; - }, - apply({bundle, bundleGraph, options, config}) { - // Dependency ids in code replaced with referenced bundle names - // Loader runtime added for bundle groups that don't have a native loader (e.g. HTML/CSS/Worker - isURL?), - // and which are not loaded by a parent bundle. - // Loaders also added for modules that were moved to a separate bundle because they are a different type - // (e.g. WASM, HTML). These should be preloaded prior to the bundle being executed. Replace the entry asset(s) - // with the preload module. - - if (bundle.type !== 'js') { - return; - } - - let {asyncDependencies, otherDependencies} = getDependencies(bundle); - - let assets = []; - for (let dependency of asyncDependencies) { - let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); - if (resolved == null) { - continue; - } - - if (resolved.type === 'asset') { - if (!bundle.env.shouldScopeHoist) { - // If this bundle already has the asset this dependency references, - // return a simple runtime of `Promise.resolve(internalRequire(assetId))`. - // The linker handles this for scope-hoisting. - assets.push({ - filePath: __filename, - code: `module.exports = Promise.resolve(module.bundle.root(${JSON.stringify( - bundleGraph.getAssetPublicId(resolved.value), - )}))`, - dependency, - env: {sourceType: 'module'}, - }); - } - } else { - // Resolve the dependency to a bundle. If inline, export the dependency id, - // which will be replaced with the contents of that bundle later. - let referencedBundle = bundleGraph.getReferencedBundle( - dependency, - bundle, - ); - if (referencedBundle?.bundleBehavior === 'inline') { - assets.push({ - filePath: path.join( - __dirname, - `/bundles/${referencedBundle.id}.js`, - ), - code: `module.exports = Promise.resolve(${JSON.stringify( - dependency.id, - )});`, - dependency, - env: {sourceType: 'module'}, - }); - continue; - } - - let loaderRuntime = getLoaderRuntime({ - bundle, - dependency, - bundleGraph, - bundleGroup: resolved.value, - options, - }); - - if (loaderRuntime != null) { - assets.push(loaderRuntime); - } - } - } - - for (let dependency of otherDependencies) { - // Resolve the dependency to a bundle. If inline, export the dependency id, - // which will be replaced with the contents of that bundle later. - let referencedBundle = bundleGraph.getReferencedBundle( - dependency, - bundle, - ); - if (referencedBundle?.bundleBehavior === 'inline') { - assets.push({ - filePath: path.join(__dirname, `/bundles/${referencedBundle.id}.js`), - code: `module.exports = ${JSON.stringify(dependency.id)};`, - dependency, - env: {sourceType: 'module'}, - }); - continue; - } - - // Otherwise, try to resolve the dependency to an external bundle group - // and insert a URL to that bundle. - let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); - if (dependency.specifierType === 'url' && resolved == null) { - // If a URL dependency was not able to be resolved, add a runtime that - // exports the original specifier. - assets.push({ - filePath: __filename, - code: `module.exports = ${JSON.stringify(dependency.specifier)}`, - dependency, - env: {sourceType: 'module'}, - }); - continue; - } - - if (resolved == null || resolved.type !== 'bundle_group') { - continue; - } - - let bundleGroup = resolved.value; - let mainBundle = nullthrows( - bundleGraph.getBundlesInBundleGroup(bundleGroup).find(b => { - let entries = b.getEntryAssets(); - return entries.some(e => bundleGroup.entryAssetId === e.id); - }), - ); - - // Skip URL runtimes for library builds. This is handled in packaging so that - // the url is inlined and statically analyzable. - if (bundle.env.isLibrary && mainBundle.bundleBehavior !== 'isolated') { - continue; - } - - // URL dependency or not, fall back to including a runtime that exports the url - assets.push(getURLRuntime(dependency, bundle, mainBundle, options)); - } - - // In development, bundles can be created lazily. This means that the parent bundle may not - // know about all of the sibling bundles of a child when it is written for the first time. - // Therefore, we need to also ensure that the siblings are loaded when the child loads. - if (options.shouldBuildLazily && bundle.env.outputFormat === 'global') { - let referenced = bundleGraph.getReferencedBundles(bundle); - for (let referencedBundle of referenced) { - let loaders = getLoaders(bundle.env); - if (!loaders) { - continue; - } - - let loader = loaders[referencedBundle.type]; - if (!loader) { - continue; - } - - let relativePathExpr = getRelativePathExpr( - bundle, - referencedBundle, - options, - ); - let loaderCode = `require(${JSON.stringify( - loader, - )})( ${getAbsoluteUrlExpr(relativePathExpr, bundle)})`; - assets.push({ - filePath: __filename, - code: loaderCode, - isEntry: true, - env: {sourceType: 'module'}, - }); - } - } - - if ( - shouldUseRuntimeManifest(bundle, options) && - bundleGraph - .getChildBundles(bundle) - .some(b => b.bundleBehavior !== 'inline') && - isNewContext(bundle, bundleGraph) - ) { - assets.push({ - filePath: __filename, - code: getRegisterCode(bundle, bundleGraph), - isEntry: true, - env: {sourceType: 'module'}, - priority: getManifestBundlePriority( - bundleGraph, - bundle, - config.splitManifestThreshold, - ), - }); - } - - return assets; - }, -}): Runtime); - -function getDependencies(bundle: NamedBundle): {| - asyncDependencies: Array, - otherDependencies: Array, -|} { - let cachedDependencies = bundleDependencies.get(bundle); - - if (cachedDependencies) { - return cachedDependencies; - } else { - let asyncDependencies = []; - let otherDependencies = []; - bundle.traverse(node => { - if (node.type !== 'dependency') { - return; - } - - let dependency = node.value; - if ( - dependency.priority === 'lazy' && - dependency.specifierType !== 'url' - ) { - asyncDependencies.push(dependency); - } else { - otherDependencies.push(dependency); - } - }); - bundleDependencies.set(bundle, {asyncDependencies, otherDependencies}); - return {asyncDependencies, otherDependencies}; - } -} - -function getLoaderRuntime({ - bundle, - dependency, - bundleGroup, - bundleGraph, - options, -}: {| - bundle: NamedBundle, - dependency: Dependency, - bundleGroup: BundleGroup, - bundleGraph: BundleGraph, - options: PluginOptions, -|}): ?RuntimeAsset { - let loaders = getLoaders(bundle.env); - if (loaders == null) { - return; - } - - let externalBundles = bundleGraph.getBundlesInBundleGroup(bundleGroup); - let mainBundle = nullthrows( - externalBundles.find( - bundle => bundle.getMainEntry()?.id === bundleGroup.entryAssetId, - ), - ); - - // CommonJS is a synchronous module system, so there is no need to load bundles in parallel. - // Importing of the other bundles will be handled by the bundle group entry. - // Do the same thing in library mode for ES modules, as we are building for another bundler - // and the imports for sibling bundles will be in the target bundle. - - // Previously we also did this when building lazily, however it seemed to cause issues in some cases. - // The original comment as to why is left here, in case a future traveller is trying to fix that issue: - // > [...] the runtime itself could get deduplicated and only exist in the parent. This causes errors if an - // > old version of the parent without the runtime - // > is already loaded. - if (bundle.env.outputFormat === 'commonjs' || bundle.env.isLibrary) { - externalBundles = [mainBundle]; - } else { - // Otherwise, load the bundle group entry after the others. - externalBundles.splice(externalBundles.indexOf(mainBundle), 1); - externalBundles.reverse().push(mainBundle); - } - - // Determine if we need to add a dynamic import() polyfill, or if all target browsers support it natively. - let needsDynamicImportPolyfill = - !bundle.env.isLibrary && !bundle.env.supports('dynamic-import', true); - - let needsEsmLoadPrelude = false; - let loaderModules = []; - - for (let to of externalBundles) { - let loader = loaders[to.type]; - if (!loader) { - continue; - } - - if ( - to.type === 'js' && - to.env.outputFormat === 'esmodule' && - !needsDynamicImportPolyfill && - shouldUseRuntimeManifest(bundle, options) - ) { - loaderModules.push(`load(${JSON.stringify(to.publicId)})`); - needsEsmLoadPrelude = true; - continue; - } - - let relativePathExpr = getRelativePathExpr(bundle, to, options); - - // Use esmodule loader if possible - if (to.type === 'js' && to.env.outputFormat === 'esmodule') { - if (!needsDynamicImportPolyfill) { - loaderModules.push(`__parcel__import__("./" + ${relativePathExpr})`); - continue; - } - - loader = nullthrows( - loaders.IMPORT_POLYFILL, - `No import() polyfill available for context '${bundle.env.context}'`, - ); - } else if (to.type === 'js' && to.env.outputFormat === 'commonjs') { - loaderModules.push( - `Promise.resolve(__parcel__require__("./" + ${relativePathExpr}))`, - ); - continue; - } - - let absoluteUrlExpr = shouldUseRuntimeManifest(bundle, options) - ? `require('./helpers/bundle-manifest').resolve(${JSON.stringify( - to.publicId, - )})` - : getAbsoluteUrlExpr(relativePathExpr, bundle); - let code = `require(${JSON.stringify(loader)})(${absoluteUrlExpr})`; - - // In development, clear the require cache when an error occurs so the - // user can try again (e.g. after fixing a build error). - if ( - options.mode === 'development' && - bundle.env.outputFormat === 'global' - ) { - code += - '.catch(err => {delete module.bundle.cache[module.id]; throw err;})'; - } - loaderModules.push(code); - } - - // Similar to the comment above, this also used to be skipped when shouldBuildLazily was true, - // however it caused issues where a bundle group contained multiple bundles. - if (bundle.env.context === 'browser') { - loaderModules.push( - ...externalBundles - // TODO: Allow css to preload resources as well - .filter(to => to.type === 'js') - .flatMap(from => { - let {preload, prefetch} = getHintedBundleGroups(bundleGraph, from); - - return [ - ...getHintLoaders( - bundleGraph, - bundle, - preload, - BROWSER_PRELOAD_LOADER, - options, - ), - ...getHintLoaders( - bundleGraph, - bundle, - prefetch, - BROWSER_PREFETCH_LOADER, - options, - ), - ]; - }), - ); - } - - if (loaderModules.length === 0) { - return; - } - - let loaderCode = loaderModules.join(', '); - if (loaderModules.length > 1) { - loaderCode = `Promise.all([${loaderCode}])`; - } else { - loaderCode = `(${loaderCode})`; - } - - if (mainBundle.type === 'js') { - let parcelRequire = bundle.env.shouldScopeHoist - ? 'parcelRequire' - : 'module.bundle.root'; - loaderCode += `.then(() => ${parcelRequire}('${bundleGraph.getAssetPublicId( - bundleGraph.getAssetById(bundleGroup.entryAssetId), - )}'))`; - } - - if (needsEsmLoadPrelude && options.featureFlags.importRetry) { - loaderCode = ` - Object.defineProperty(module, 'exports', { get: () => { - let load = require('./helpers/browser/esm-js-loader-retry'); - return ${loaderCode}.then((v) => { - Object.defineProperty(module, "exports", { value: Promise.resolve(v) }) - return v - }); - }})`; - - return { - filePath: __filename, - code: loaderCode, - dependency, - env: {sourceType: 'module'}, - }; - } - - let code = []; - - if (needsEsmLoadPrelude) { - code.push(`let load = require('./helpers/browser/esm-js-loader');`); - } - - code.push(`module.exports = ${loaderCode};`); - - return { - filePath: __filename, - code: code.join('\n'), - dependency, - env: {sourceType: 'module'}, - }; -} - -function getHintedBundleGroups( - bundleGraph: BundleGraph, - bundle: NamedBundle, -): {|preload: Array, prefetch: Array|} { - let preload = []; - let prefetch = []; - let {asyncDependencies} = getDependencies(bundle); - for (let dependency of asyncDependencies) { - let attributes = dependency.meta?.importAttributes; - if ( - typeof attributes === 'object' && - attributes != null && - // $FlowFixMe - (attributes.preload || attributes.prefetch) - ) { - let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); - if (resolved?.type === 'bundle_group') { - // === true for flow - if (attributes.preload === true) { - preload.push(resolved.value); - } - if (attributes.prefetch === true) { - prefetch.push(resolved.value); - } - } - } - } - - return {preload, prefetch}; -} - -function getHintLoaders( - bundleGraph: BundleGraph, - from: NamedBundle, - bundleGroups: Array, - loader: string, - options: PluginOptions, -): Array { - let hintLoaders = []; - for (let bundleGroupToPreload of bundleGroups) { - let bundlesToPreload = - bundleGraph.getBundlesInBundleGroup(bundleGroupToPreload); - - for (let bundleToPreload of bundlesToPreload) { - let relativePathExpr = getRelativePathExpr( - from, - bundleToPreload, - options, - ); - let priority = TYPE_TO_RESOURCE_PRIORITY[bundleToPreload.type]; - hintLoaders.push( - `require(${JSON.stringify(loader)})(${getAbsoluteUrlExpr( - relativePathExpr, - from, - )}, ${priority ? JSON.stringify(priority) : 'null'}, ${JSON.stringify( - bundleToPreload.target.env.outputFormat === 'esmodule', - )})`, - ); - } - } - - return hintLoaders; -} - -function isNewContext( - bundle: NamedBundle, - bundleGraph: BundleGraph, -): boolean { - let parents = bundleGraph.getParentBundles(bundle); - let isInEntryBundleGroup = bundleGraph - .getBundleGroupsContainingBundle(bundle) - .some(g => bundleGraph.isEntryBundleGroup(g)); - return ( - isInEntryBundleGroup || - parents.length === 0 || - parents.some( - parent => - parent.env.context !== bundle.env.context || parent.type !== 'js', - ) - ); -} - -function getURLRuntime( - dependency: Dependency, - from: NamedBundle, - to: NamedBundle, - options: PluginOptions, -): RuntimeAsset { - let relativePathExpr = getRelativePathExpr(from, to, options); - let code; - - if (dependency.meta.webworker === true && !from.env.isLibrary) { - code = `let workerURL = require('./helpers/get-worker-url');\n`; - if ( - from.env.outputFormat === 'esmodule' && - from.env.supports('import-meta-url') - ) { - code += `let url = new __parcel__URL__(${relativePathExpr});\n`; - code += `module.exports = workerURL(url.toString(), url.origin, ${String( - from.env.outputFormat === 'esmodule', - )});`; - } else { - code += `let bundleURL = require('./helpers/bundle-url');\n`; - code += `let url = bundleURL.getBundleURL('${from.publicId}') + ${relativePathExpr};`; - code += `module.exports = workerURL(url, bundleURL.getOrigin(url), ${String( - from.env.outputFormat === 'esmodule', - )});`; - } - } else { - code = `module.exports = ${getAbsoluteUrlExpr(relativePathExpr, from)};`; - } - - return { - filePath: __filename, - code, - dependency, - env: {sourceType: 'module'}, - }; -} - -function getRegisterCode( - entryBundle: NamedBundle, - bundleGraph: BundleGraph, -): string { - let mappings = []; - bundleGraph.traverseBundles((bundle, _, actions) => { - if (bundle.bundleBehavior === 'inline') { - return; - } - - // To make the manifest as small as possible all bundle key/values are - // serialised into a single array e.g. ['id', 'value', 'id2', 'value2']. - // `./helpers/bundle-manifest` accounts for this by iterating index by 2 - mappings.push( - bundle.publicId, - relativeBundlePath(entryBundle, nullthrows(bundle), { - leadingDotSlash: false, - }), - ); - - if (bundle !== entryBundle && isNewContext(bundle, bundleGraph)) { - for (let referenced of bundleGraph.getReferencedBundles(bundle)) { - mappings.push( - referenced.publicId, - relativeBundlePath(entryBundle, nullthrows(referenced), { - leadingDotSlash: false, - }), - ); - } - // New contexts have their own manifests, so there's no need to continue. - actions.skipChildren(); - } - }, entryBundle); - - let baseUrl = - entryBundle.env.outputFormat === 'esmodule' && - entryBundle.env.supports('import-meta-url') - ? 'new __parcel__URL__("").toString()' // <-- this isn't ideal. We should use `import.meta.url` directly but it gets replaced currently - : `require('./helpers/bundle-url').getBundleURL('${entryBundle.publicId}')`; - - return `require('./helpers/bundle-manifest').register(${baseUrl},JSON.parse(${JSON.stringify( - JSON.stringify(mappings), - )}));`; -} - -function getRelativePathExpr( - from: NamedBundle, - to: NamedBundle, - options: PluginOptions, -): string { - let relativePath = relativeBundlePath(from, to, {leadingDotSlash: false}); - let res = JSON.stringify(relativePath); - if (options.hmrOptions) { - res += ' + "?" + Date.now()'; - } - - return res; -} - -function getAbsoluteUrlExpr(relativePathExpr: string, bundle: NamedBundle) { - if ( - (bundle.env.outputFormat === 'esmodule' && - bundle.env.supports('import-meta-url')) || - bundle.env.outputFormat === 'commonjs' - ) { - // This will be compiled to new URL(url, import.meta.url) or new URL(url, 'file:' + __filename). - return `new __parcel__URL__(${relativePathExpr}).toString()`; - } else { - return `require('./helpers/bundle-url').getBundleURL('${bundle.publicId}') + ${relativePathExpr}`; - } -} - -function shouldUseRuntimeManifest( - bundle: NamedBundle, - options: PluginOptions, -): boolean { - let env = bundle.env; - return ( - !env.isLibrary && - bundle.bundleBehavior !== 'inline' && - env.isBrowser() && - options.mode === 'production' - ); -} - -function getManifestBundlePriority( - bundleGraph: BundleGraph, - bundle: NamedBundle, - threshold: number, -): $PropertyType { - let bundleSize = 0; - - bundle.traverseAssets((asset, _, actions) => { - bundleSize += asset.stats.size; - - if (bundleSize > threshold) { - actions.stop(); - } - }); - - return bundleSize > threshold ? 'parallel' : 'sync'; -} diff --git a/packages/runtimes/js/src/JSRuntime.ts b/packages/runtimes/js/src/JSRuntime.ts new file mode 100644 index 000000000..548df865a --- /dev/null +++ b/packages/runtimes/js/src/JSRuntime.ts @@ -0,0 +1,756 @@ +import type { + BundleGraph, + BundleGroup, + Dependency, + Environment, + PluginOptions, + NamedBundle, + RuntimeAsset, +} from '@atlaspack/types'; + +import {Runtime} from '@atlaspack/plugin'; +import { + relativeBundlePath, + validateSchema, + SchemaEntity, +} from '@atlaspack/utils'; +import {encodeJSONKeyComponent} from '@atlaspack/diagnostic'; +import path from 'path'; +import nullthrows from 'nullthrows'; + +// Used for as="" in preload/prefetch +const TYPE_TO_RESOURCE_PRIORITY = { + css: 'style', + js: 'script', +} as const; + +const BROWSER_PRELOAD_LOADER = './helpers/browser/preload-loader'; +const BROWSER_PREFETCH_LOADER = './helpers/browser/prefetch-loader'; + +const LOADERS = { + browser: { + css: './helpers/browser/css-loader', + html: './helpers/browser/html-loader', + js: './helpers/browser/js-loader', + wasm: './helpers/browser/wasm-loader', + IMPORT_POLYFILL: './helpers/browser/import-polyfill', + }, + worker: { + js: './helpers/worker/js-loader', + wasm: './helpers/worker/wasm-loader', + IMPORT_POLYFILL: false, + }, + node: { + css: './helpers/node/css-loader', + html: './helpers/node/html-loader', + js: './helpers/node/js-loader', + wasm: './helpers/node/wasm-loader', + IMPORT_POLYFILL: null, + }, +} as const; + +function getLoaders(ctx: Environment): + | { + // @ts-expect-error - TS2411 - Property 'IMPORT_POLYFILL' of type 'string | false | null' is not assignable to 'string' index type 'string'. + IMPORT_POLYFILL: null | false | string; + [key: string]: string; + } + | null + | undefined { + // @ts-expect-error - TS2322 - Type '{ readonly js: "./helpers/worker/js-loader"; readonly wasm: "./helpers/worker/wasm-loader"; readonly IMPORT_POLYFILL: false; }' is not assignable to type '{ [key: string]: string; IMPORT_POLYFILL: string | false | null; }'. + if (ctx.isWorker()) return LOADERS.worker; + if (ctx.isBrowser()) return LOADERS.browser; + // @ts-expect-error - TS2322 - Type '{ readonly css: "./helpers/node/css-loader"; readonly html: "./helpers/node/html-loader"; readonly js: "./helpers/node/js-loader"; readonly wasm: "./helpers/node/wasm-loader"; readonly IMPORT_POLYFILL: null; }' is not assignable to type '{ [key: string]: string; IMPORT_POLYFILL: string | false | null; }'. + if (ctx.isNode()) return LOADERS.node; + return null; +} + +// This cache should be invalidated if new dependencies get added to the bundle without the bundle objects changing +// This can happen when we reuse the BundleGraph between subsequent builds +let bundleDependencies = new WeakMap< + NamedBundle, + { + asyncDependencies: Array; + otherDependencies: Array; + } +>(); + +type JSRuntimeConfig = { + splitManifestThreshold: number; +}; + +let defaultConfig: JSRuntimeConfig = { + splitManifestThreshold: 100000, +}; + +const CONFIG_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + splitManifestThreshold: { + type: 'number', + }, + }, + additionalProperties: false, +}; + +export default new Runtime({ + async loadConfig({config, options}): Promise { + let packageKey = '@atlaspack/runtime-js'; + let conf = await config.getConfig([], { + packageKey, + }); + + if (!conf) { + return defaultConfig; + } + validateSchema.diagnostic( + CONFIG_SCHEMA, + { + data: conf?.contents, + source: await options.inputFS.readFile(conf.filePath, 'utf8'), + filePath: conf.filePath, + prependKey: `/${encodeJSONKeyComponent(packageKey)}`, + }, + packageKey, + `Invalid config for ${packageKey}`, + ); + + return { + ...defaultConfig, + ...conf?.contents, + }; + }, + apply({bundle, bundleGraph, options, config}) { + // Dependency ids in code replaced with referenced bundle names + // Loader runtime added for bundle groups that don't have a native loader (e.g. HTML/CSS/Worker - isURL?), + // and which are not loaded by a parent bundle. + // Loaders also added for modules that were moved to a separate bundle because they are a different type + // (e.g. WASM, HTML). These should be preloaded prior to the bundle being executed. Replace the entry asset(s) + // with the preload module. + + if (bundle.type !== 'js') { + return; + } + + let {asyncDependencies, otherDependencies} = getDependencies(bundle); + + let assets: Array = []; + for (let dependency of asyncDependencies) { + let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); + if (resolved == null) { + continue; + } + + if (resolved.type === 'asset') { + if (!bundle.env.shouldScopeHoist) { + // If this bundle already has the asset this dependency references, + // return a simple runtime of `Promise.resolve(internalRequire(assetId))`. + // The linker handles this for scope-hoisting. + assets.push({ + filePath: __filename, + code: `module.exports = Promise.resolve(module.bundle.root(${JSON.stringify( + bundleGraph.getAssetPublicId(resolved.value), + )}))`, + dependency, + env: {sourceType: 'module'}, + }); + } + } else { + // Resolve the dependency to a bundle. If inline, export the dependency id, + // which will be replaced with the contents of that bundle later. + let referencedBundle = bundleGraph.getReferencedBundle( + dependency, + bundle, + ); + if (referencedBundle?.bundleBehavior === 'inline') { + assets.push({ + filePath: path.join( + __dirname, + `/bundles/${referencedBundle.id}.js`, + ), + code: `module.exports = Promise.resolve(${JSON.stringify( + dependency.id, + )});`, + dependency, + env: {sourceType: 'module'}, + }); + continue; + } + + let loaderRuntime = getLoaderRuntime({ + bundle, + dependency, + bundleGraph, + bundleGroup: resolved.value, + options, + }); + + if (loaderRuntime != null) { + assets.push(loaderRuntime); + } + } + } + + for (let dependency of otherDependencies) { + // Resolve the dependency to a bundle. If inline, export the dependency id, + // which will be replaced with the contents of that bundle later. + let referencedBundle = bundleGraph.getReferencedBundle( + dependency, + bundle, + ); + if (referencedBundle?.bundleBehavior === 'inline') { + assets.push({ + filePath: path.join(__dirname, `/bundles/${referencedBundle.id}.js`), + code: `module.exports = ${JSON.stringify(dependency.id)};`, + dependency, + env: {sourceType: 'module'}, + }); + continue; + } + + // Otherwise, try to resolve the dependency to an external bundle group + // and insert a URL to that bundle. + let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); + if (dependency.specifierType === 'url' && resolved == null) { + // If a URL dependency was not able to be resolved, add a runtime that + // exports the original specifier. + assets.push({ + filePath: __filename, + code: `module.exports = ${JSON.stringify(dependency.specifier)}`, + dependency, + env: {sourceType: 'module'}, + }); + continue; + } + + if (resolved == null || resolved.type !== 'bundle_group') { + continue; + } + + let bundleGroup = resolved.value; + let mainBundle = nullthrows( + bundleGraph.getBundlesInBundleGroup(bundleGroup).find((b) => { + let entries = b.getEntryAssets(); + return entries.some((e) => bundleGroup.entryAssetId === e.id); + }), + ); + + // Skip URL runtimes for library builds. This is handled in packaging so that + // the url is inlined and statically analyzable. + if (bundle.env.isLibrary && mainBundle.bundleBehavior !== 'isolated') { + continue; + } + + // URL dependency or not, fall back to including a runtime that exports the url + assets.push(getURLRuntime(dependency, bundle, mainBundle, options)); + } + + // In development, bundles can be created lazily. This means that the parent bundle may not + // know about all of the sibling bundles of a child when it is written for the first time. + // Therefore, we need to also ensure that the siblings are loaded when the child loads. + if (options.shouldBuildLazily && bundle.env.outputFormat === 'global') { + let referenced = bundleGraph.getReferencedBundles(bundle); + for (let referencedBundle of referenced) { + let loaders = getLoaders(bundle.env); + if (!loaders) { + continue; + } + + let loader = loaders[referencedBundle.type]; + if (!loader) { + continue; + } + + let relativePathExpr = getRelativePathExpr( + bundle, + referencedBundle, + options, + ); + let loaderCode = `require(${JSON.stringify( + loader, + )})( ${getAbsoluteUrlExpr(relativePathExpr, bundle)})`; + assets.push({ + filePath: __filename, + code: loaderCode, + isEntry: true, + env: {sourceType: 'module'}, + }); + } + } + + if ( + shouldUseRuntimeManifest(bundle, options) && + bundleGraph + .getChildBundles(bundle) + .some((b) => b.bundleBehavior !== 'inline') && + isNewContext(bundle, bundleGraph) + ) { + assets.push({ + filePath: __filename, + code: getRegisterCode(bundle, bundleGraph), + isEntry: true, + env: {sourceType: 'module'}, + priority: getManifestBundlePriority( + bundleGraph, + bundle, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + config.splitManifestThreshold, + ), + }); + } + + return assets; + }, +}) as Runtime; + +function getDependencies(bundle: NamedBundle): { + asyncDependencies: Array; + otherDependencies: Array; +} { + let cachedDependencies = bundleDependencies.get(bundle); + + if (cachedDependencies) { + return cachedDependencies; + } else { + let asyncDependencies: Array = []; + let otherDependencies: Array = []; + bundle.traverse((node) => { + if (node.type !== 'dependency') { + return; + } + + let dependency = node.value; + if ( + dependency.priority === 'lazy' && + dependency.specifierType !== 'url' + ) { + asyncDependencies.push(dependency); + } else { + otherDependencies.push(dependency); + } + }); + bundleDependencies.set(bundle, {asyncDependencies, otherDependencies}); + return {asyncDependencies, otherDependencies}; + } +} + +function getLoaderRuntime({ + bundle, + dependency, + bundleGroup, + bundleGraph, + options, +}: { + bundle: NamedBundle; + dependency: Dependency; + bundleGroup: BundleGroup; + bundleGraph: BundleGraph; + options: PluginOptions; +}): RuntimeAsset | null | undefined { + let loaders = getLoaders(bundle.env); + if (loaders == null) { + return; + } + + let externalBundles = bundleGraph.getBundlesInBundleGroup(bundleGroup); + let mainBundle = nullthrows( + externalBundles.find( + (bundle) => bundle.getMainEntry()?.id === bundleGroup.entryAssetId, + ), + ); + + // CommonJS is a synchronous module system, so there is no need to load bundles in parallel. + // Importing of the other bundles will be handled by the bundle group entry. + // Do the same thing in library mode for ES modules, as we are building for another bundler + // and the imports for sibling bundles will be in the target bundle. + + // Previously we also did this when building lazily, however it seemed to cause issues in some cases. + // The original comment as to why is left here, in case a future traveller is trying to fix that issue: + // > [...] the runtime itself could get deduplicated and only exist in the parent. This causes errors if an + // > old version of the parent without the runtime + // > is already loaded. + if (bundle.env.outputFormat === 'commonjs' || bundle.env.isLibrary) { + externalBundles = [mainBundle]; + } else { + // Otherwise, load the bundle group entry after the others. + externalBundles.splice(externalBundles.indexOf(mainBundle), 1); + externalBundles.reverse().push(mainBundle); + } + + // Determine if we need to add a dynamic import() polyfill, or if all target browsers support it natively. + let needsDynamicImportPolyfill = + !bundle.env.isLibrary && !bundle.env.supports('dynamic-import', true); + + let needsEsmLoadPrelude = false; + let loaderModules: Array = []; + + for (let to of externalBundles) { + let loader = loaders[to.type]; + if (!loader) { + continue; + } + + if ( + to.type === 'js' && + to.env.outputFormat === 'esmodule' && + !needsDynamicImportPolyfill && + shouldUseRuntimeManifest(bundle, options) + ) { + loaderModules.push(`load(${JSON.stringify(to.publicId)})`); + needsEsmLoadPrelude = true; + continue; + } + + let relativePathExpr = getRelativePathExpr(bundle, to, options); + + // Use esmodule loader if possible + if (to.type === 'js' && to.env.outputFormat === 'esmodule') { + if (!needsDynamicImportPolyfill) { + loaderModules.push(`__parcel__import__("./" + ${relativePathExpr})`); + continue; + } + + // @ts-expect-error - TS2322 - Type 'string | false' is not assignable to type 'string'. + loader = nullthrows( + loaders.IMPORT_POLYFILL, + `No import() polyfill available for context '${bundle.env.context}'`, + ); + } else if (to.type === 'js' && to.env.outputFormat === 'commonjs') { + loaderModules.push( + `Promise.resolve(__parcel__require__("./" + ${relativePathExpr}))`, + ); + continue; + } + + let absoluteUrlExpr = shouldUseRuntimeManifest(bundle, options) + ? `require('./helpers/bundle-manifest').resolve(${JSON.stringify( + to.publicId, + )})` + : getAbsoluteUrlExpr(relativePathExpr, bundle); + let code = `require(${JSON.stringify(loader)})(${absoluteUrlExpr})`; + + // In development, clear the require cache when an error occurs so the + // user can try again (e.g. after fixing a build error). + if ( + options.mode === 'development' && + bundle.env.outputFormat === 'global' + ) { + code += + '.catch(err => {delete module.bundle.cache[module.id]; throw err;})'; + } + loaderModules.push(code); + } + + // Similar to the comment above, this also used to be skipped when shouldBuildLazily was true, + // however it caused issues where a bundle group contained multiple bundles. + if (bundle.env.context === 'browser') { + loaderModules.push( + ...externalBundles + // TODO: Allow css to preload resources as well + .filter((to) => to.type === 'js') + .flatMap((from) => { + let {preload, prefetch} = getHintedBundleGroups(bundleGraph, from); + + return [ + ...getHintLoaders( + bundleGraph, + bundle, + preload, + BROWSER_PRELOAD_LOADER, + options, + ), + ...getHintLoaders( + bundleGraph, + bundle, + prefetch, + BROWSER_PREFETCH_LOADER, + options, + ), + ]; + }), + ); + } + + if (loaderModules.length === 0) { + return; + } + + let loaderCode = loaderModules.join(', '); + if (loaderModules.length > 1) { + loaderCode = `Promise.all([${loaderCode}])`; + } else { + loaderCode = `(${loaderCode})`; + } + + if (mainBundle.type === 'js') { + let parcelRequire = bundle.env.shouldScopeHoist + ? 'parcelRequire' + : 'module.bundle.root'; + loaderCode += `.then(() => ${parcelRequire}('${bundleGraph.getAssetPublicId( + bundleGraph.getAssetById(bundleGroup.entryAssetId), + )}'))`; + } + + if (needsEsmLoadPrelude && options.featureFlags.importRetry) { + loaderCode = ` + Object.defineProperty(module, 'exports', { get: () => { + let load = require('./helpers/browser/esm-js-loader-retry'); + return ${loaderCode}.then((v) => { + Object.defineProperty(module, "exports", { value: Promise.resolve(v) }) + return v + }); + }})`; + + return { + filePath: __filename, + code: loaderCode, + dependency, + env: {sourceType: 'module'}, + }; + } + + let code: Array = []; + + if (needsEsmLoadPrelude) { + code.push(`let load = require('./helpers/browser/esm-js-loader');`); + } + + code.push(`module.exports = ${loaderCode};`); + + return { + filePath: __filename, + code: code.join('\n'), + dependency, + env: {sourceType: 'module'}, + }; +} + +function getHintedBundleGroups( + bundleGraph: BundleGraph, + bundle: NamedBundle, +): { + preload: Array; + prefetch: Array; +} { + let preload: Array = []; + let prefetch: Array = []; + let {asyncDependencies} = getDependencies(bundle); + for (let dependency of asyncDependencies) { + let attributes = dependency.meta?.importAttributes; + if ( + typeof attributes === 'object' && + attributes != null && + // $FlowFixMe + // @ts-expect-error - TS2339 - Property 'preload' does not exist on type 'JSONValue[] | JSONObject'. | TS2339 - Property 'prefetch' does not exist on type 'JSONValue[] | JSONObject'. + (attributes.preload || attributes.prefetch) + ) { + let resolved = bundleGraph.resolveAsyncDependency(dependency, bundle); + if (resolved?.type === 'bundle_group') { + // === true for flow + // @ts-expect-error - TS2339 - Property 'preload' does not exist on type 'JSONValue[] | JSONObject'. + if (attributes.preload === true) { + preload.push(resolved.value); + } + // @ts-expect-error - TS2339 - Property 'prefetch' does not exist on type 'JSONValue[] | JSONObject'. + if (attributes.prefetch === true) { + prefetch.push(resolved.value); + } + } + } + } + + return {preload, prefetch}; +} + +function getHintLoaders( + bundleGraph: BundleGraph, + from: NamedBundle, + bundleGroups: Array, + loader: string, + options: PluginOptions, +): Array { + let hintLoaders: Array = []; + for (let bundleGroupToPreload of bundleGroups) { + let bundlesToPreload = + bundleGraph.getBundlesInBundleGroup(bundleGroupToPreload); + + for (let bundleToPreload of bundlesToPreload) { + let relativePathExpr = getRelativePathExpr( + from, + bundleToPreload, + options, + ); + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly css: "style"; readonly js: "script"; }'. + let priority = TYPE_TO_RESOURCE_PRIORITY[bundleToPreload.type]; + hintLoaders.push( + `require(${JSON.stringify(loader)})(${getAbsoluteUrlExpr( + relativePathExpr, + from, + )}, ${priority ? JSON.stringify(priority) : 'null'}, ${JSON.stringify( + bundleToPreload.target.env.outputFormat === 'esmodule', + )})`, + ); + } + } + + return hintLoaders; +} + +function isNewContext( + bundle: NamedBundle, + bundleGraph: BundleGraph, +): boolean { + let parents = bundleGraph.getParentBundles(bundle); + let isInEntryBundleGroup = bundleGraph + .getBundleGroupsContainingBundle(bundle) + .some((g) => bundleGraph.isEntryBundleGroup(g)); + return ( + isInEntryBundleGroup || + parents.length === 0 || + parents.some( + (parent) => + parent.env.context !== bundle.env.context || parent.type !== 'js', + ) + ); +} + +function getURLRuntime( + dependency: Dependency, + from: NamedBundle, + to: NamedBundle, + options: PluginOptions, +): RuntimeAsset { + let relativePathExpr = getRelativePathExpr(from, to, options); + let code; + + if (dependency.meta.webworker === true && !from.env.isLibrary) { + code = `let workerURL = require('./helpers/get-worker-url');\n`; + if ( + from.env.outputFormat === 'esmodule' && + from.env.supports('import-meta-url') + ) { + code += `let url = new __parcel__URL__(${relativePathExpr});\n`; + code += `module.exports = workerURL(url.toString(), url.origin, ${String( + from.env.outputFormat === 'esmodule', + )});`; + } else { + code += `let bundleURL = require('./helpers/bundle-url');\n`; + code += `let url = bundleURL.getBundleURL('${from.publicId}') + ${relativePathExpr};`; + code += `module.exports = workerURL(url, bundleURL.getOrigin(url), ${String( + from.env.outputFormat === 'esmodule', + )});`; + } + } else { + code = `module.exports = ${getAbsoluteUrlExpr(relativePathExpr, from)};`; + } + + return { + filePath: __filename, + code, + dependency, + env: {sourceType: 'module'}, + }; +} + +function getRegisterCode( + entryBundle: NamedBundle, + bundleGraph: BundleGraph, +): string { + let mappings: Array = []; + bundleGraph.traverseBundles((bundle, _, actions) => { + if (bundle.bundleBehavior === 'inline') { + return; + } + + // To make the manifest as small as possible all bundle key/values are + // serialised into a single array e.g. ['id', 'value', 'id2', 'value2']. + // `./helpers/bundle-manifest` accounts for this by iterating index by 2 + mappings.push( + bundle.publicId, + relativeBundlePath(entryBundle, nullthrows(bundle), { + leadingDotSlash: false, + }), + ); + + if (bundle !== entryBundle && isNewContext(bundle, bundleGraph)) { + for (let referenced of bundleGraph.getReferencedBundles(bundle)) { + mappings.push( + referenced.publicId, + relativeBundlePath(entryBundle, nullthrows(referenced), { + leadingDotSlash: false, + }), + ); + } + // New contexts have their own manifests, so there's no need to continue. + actions.skipChildren(); + } + }, entryBundle); + + let baseUrl = + entryBundle.env.outputFormat === 'esmodule' && + entryBundle.env.supports('import-meta-url') + ? 'new __parcel__URL__("").toString()' // <-- this isn't ideal. We should use `import.meta.url` directly but it gets replaced currently + : `require('./helpers/bundle-url').getBundleURL('${entryBundle.publicId}')`; + + return `require('./helpers/bundle-manifest').register(${baseUrl},JSON.parse(${JSON.stringify( + JSON.stringify(mappings), + )}));`; +} + +function getRelativePathExpr( + from: NamedBundle, + to: NamedBundle, + options: PluginOptions, +): string { + let relativePath = relativeBundlePath(from, to, {leadingDotSlash: false}); + let res = JSON.stringify(relativePath); + if (options.hmrOptions) { + res += ' + "?" + Date.now()'; + } + + return res; +} + +function getAbsoluteUrlExpr(relativePathExpr: string, bundle: NamedBundle) { + if ( + (bundle.env.outputFormat === 'esmodule' && + bundle.env.supports('import-meta-url')) || + bundle.env.outputFormat === 'commonjs' + ) { + // This will be compiled to new URL(url, import.meta.url) or new URL(url, 'file:' + __filename). + return `new __parcel__URL__(${relativePathExpr}).toString()`; + } else { + return `require('./helpers/bundle-url').getBundleURL('${bundle.publicId}') + ${relativePathExpr}`; + } +} + +function shouldUseRuntimeManifest( + bundle: NamedBundle, + options: PluginOptions, +): boolean { + let env = bundle.env; + return ( + !env.isLibrary && + bundle.bundleBehavior !== 'inline' && + env.isBrowser() && + options.mode === 'production' + ); +} + +function getManifestBundlePriority( + bundleGraph: BundleGraph, + bundle: NamedBundle, + threshold: number, +): RuntimeAsset['priority'] { + let bundleSize = 0; + + bundle.traverseAssets((asset, _, actions) => { + bundleSize += asset.stats.size; + + if (bundleSize > threshold) { + actions.stop(); + } + }); + + return bundleSize > threshold ? 'parallel' : 'sync'; +} diff --git a/packages/runtimes/js/src/helpers/browser/esm-js-loader-retry.js b/packages/runtimes/js/src/helpers/browser/esm-js-loader-retry.js index 1f729b6b9..bb7e045d7 100644 --- a/packages/runtimes/js/src/helpers/browser/esm-js-loader-retry.js +++ b/packages/runtimes/js/src/helpers/browser/esm-js-loader-retry.js @@ -4,7 +4,7 @@ async function load(id) { } if (!globalThis.navigator.onLine) { - await new Promise(res => + await new Promise((res) => globalThis.addEventListener('online', res, {once: true}), ); } diff --git a/packages/runtimes/js/src/helpers/browser/import-polyfill.js b/packages/runtimes/js/src/helpers/browser/import-polyfill.js index d4ff6ce5b..8cf9cdbfa 100644 --- a/packages/runtimes/js/src/helpers/browser/import-polyfill.js +++ b/packages/runtimes/js/src/helpers/browser/import-polyfill.js @@ -4,7 +4,7 @@ module.exports = cacheLoader(function importModule(bundle) { return new Promise((resolve, reject) => { // Add a global function to handle when the script loads. let globalName = `i${('' + Math.random()).slice(2)}`; - global[globalName] = m => { + global[globalName] = (m) => { resolve(m); cleanup(); }; diff --git a/packages/runtimes/react-refresh/package.json b/packages/runtimes/react-refresh/package.json index 966e44667..42554d843 100644 --- a/packages/runtimes/react-refresh/package.json +++ b/packages/runtimes/react-refresh/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/ReactRefreshRuntime.js", - "source": "src/ReactRefreshRuntime.js", + "types": "src/ReactRefreshRuntime.ts", + "source": "src/ReactRefreshRuntime.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/runtimes/react-refresh/src/ReactRefreshRuntime.js b/packages/runtimes/react-refresh/src/ReactRefreshRuntime.js deleted file mode 100644 index d91075535..000000000 --- a/packages/runtimes/react-refresh/src/ReactRefreshRuntime.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow strict-local - -import {Runtime} from '@atlaspack/plugin'; -import {loadConfig} from '@atlaspack/utils'; -// $FlowFixMe Package json is untyped -import {version} from 'react-refresh/package.json'; - -const CODE = ` -var Refresh = require('react-refresh/runtime'); -var ErrorOverlay = require('react-error-overlay'); -window.__REACT_REFRESH_VERSION_RUNTIME = '${version}'; - -Refresh.injectIntoGlobalHook(window); -window.$RefreshReg$ = function() {}; -window.$RefreshSig$ = function() { - return function(type) { - return type; - }; -}; - -ErrorOverlay.setEditorHandler(function editorHandler(errorLocation) { - let file = \`\${errorLocation.fileName}:\${errorLocation.lineNumber || 1}:\${errorLocation.colNumber || 1}\`; - fetch(\`/__parcel_launch_editor?file=\${encodeURIComponent(file)}\`); -}); - -ErrorOverlay.startReportingRuntimeErrors({ - onError: function () {}, -}); - -window.addEventListener('parcelhmraccept', () => { - ErrorOverlay.dismissRuntimeErrors(); -}); -`; - -export default (new Runtime({ - async apply({bundle, options}) { - if ( - bundle.type !== 'js' || - !options.hmrOptions || - !bundle.env.isBrowser() || - bundle.env.isLibrary || - bundle.env.isWorker() || - bundle.env.isWorklet() || - options.mode !== 'development' || - bundle.env.sourceType !== 'module' - ) { - return; - } - - let entries = bundle.getEntryAssets(); - for (let entry of entries) { - // TODO: do this in loadConfig - but it doesn't have access to the bundle... - let pkg = await loadConfig( - options.inputFS, - entry.filePath, - ['package.json'], - options.projectRoot, - ); - if ( - pkg?.config?.dependencies?.react || - pkg?.config?.devDependencies?.react || - pkg?.config?.peerDependencies?.react - ) { - return { - filePath: __filename, - code: CODE, - isEntry: true, - }; - } - } - }, -}): Runtime); diff --git a/packages/runtimes/react-refresh/src/ReactRefreshRuntime.ts b/packages/runtimes/react-refresh/src/ReactRefreshRuntime.ts new file mode 100644 index 000000000..b35f78946 --- /dev/null +++ b/packages/runtimes/react-refresh/src/ReactRefreshRuntime.ts @@ -0,0 +1,70 @@ +import {Runtime} from '@atlaspack/plugin'; +import {loadConfig} from '@atlaspack/utils'; +// @ts-expect-error - TS2732 - Cannot find module 'react-refresh/package.json'. Consider using '--resolveJsonModule' to import module with '.json' extension. +import {version} from 'react-refresh/package.json'; + +const CODE = ` +var Refresh = require('react-refresh/runtime'); +var ErrorOverlay = require('react-error-overlay'); +window.__REACT_REFRESH_VERSION_RUNTIME = '${version}'; + +Refresh.injectIntoGlobalHook(window); +window.$RefreshReg$ = function() {}; +window.$RefreshSig$ = function() { + return function(type) { + return type; + }; +}; + +ErrorOverlay.setEditorHandler(function editorHandler(errorLocation) { + let file = \`\${errorLocation.fileName}:\${errorLocation.lineNumber || 1}:\${errorLocation.colNumber || 1}\`; + fetch(\`/__parcel_launch_editor?file=\${encodeURIComponent(file)}\`); +}); + +ErrorOverlay.startReportingRuntimeErrors({ + onError: function () {}, +}); + +window.addEventListener('parcelhmraccept', () => { + ErrorOverlay.dismissRuntimeErrors(); +}); +`; + +export default new Runtime({ + async apply({bundle, options}) { + if ( + bundle.type !== 'js' || + !options.hmrOptions || + !bundle.env.isBrowser() || + bundle.env.isLibrary || + bundle.env.isWorker() || + bundle.env.isWorklet() || + options.mode !== 'development' || + bundle.env.sourceType !== 'module' + ) { + return; + } + + let entries = bundle.getEntryAssets(); + for (let entry of entries) { + // TODO: do this in loadConfig - but it doesn't have access to the bundle... + let pkg = await loadConfig( + options.inputFS, + entry.filePath, + ['package.json'], + options.projectRoot, + ); + if ( + pkg?.config?.dependencies?.react || + pkg?.config?.devDependencies?.react || + pkg?.config?.peerDependencies?.react + ) { + return { + filePath: __filename, + code: CODE, + isEntry: true, + }; + } + } + }, +}) as Runtime; diff --git a/packages/runtimes/service-worker/package.json b/packages/runtimes/service-worker/package.json index 6a0f88e22..21172142e 100644 --- a/packages/runtimes/service-worker/package.json +++ b/packages/runtimes/service-worker/package.json @@ -10,7 +10,7 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "./lib/ServiceWorkerRuntime.js", - "source": "./src/ServiceWorkerRuntime.js", + "source": "./src/ServiceWorkerRuntime.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/runtimes/service-worker/src/ServiceWorkerRuntime.js b/packages/runtimes/service-worker/src/ServiceWorkerRuntime.js deleted file mode 100644 index 79627e092..000000000 --- a/packages/runtimes/service-worker/src/ServiceWorkerRuntime.js +++ /dev/null @@ -1,50 +0,0 @@ -// @flow -import {Runtime} from '@atlaspack/plugin'; -import {urlJoin} from '@atlaspack/utils'; - -export default (new Runtime({ - apply({bundle, bundleGraph}) { - if (bundle.env.context !== 'service-worker') { - return []; - } - - let asset = bundle.traverse((node, _, actions) => { - if ( - node.type === 'dependency' && - node.value.specifier === '@atlaspack/service-worker' && - !bundleGraph.isDependencySkipped(node.value) - ) { - actions.stop(); - return bundleGraph.getResolvedAsset(node.value, bundle); - } - }); - - if (!asset) { - return []; - } - - let manifest = []; - bundleGraph.traverseBundles(b => { - if (b.bundleBehavior === 'inline' || b.id === bundle.id) { - return; - } - - manifest.push(urlJoin(b.target.publicUrl, b.name)); - }); - - let code = `import {_register} from '@atlaspack/service-worker'; -const manifest = ${JSON.stringify(manifest)}; -const version = ${JSON.stringify(bundle.hashReference)}; -_register(manifest, version); -`; - - return [ - { - filePath: asset.filePath, - code, - isEntry: true, - env: {sourceType: 'module'}, - }, - ]; - }, -}): Runtime); diff --git a/packages/runtimes/service-worker/src/ServiceWorkerRuntime.ts b/packages/runtimes/service-worker/src/ServiceWorkerRuntime.ts new file mode 100644 index 000000000..90b12b24f --- /dev/null +++ b/packages/runtimes/service-worker/src/ServiceWorkerRuntime.ts @@ -0,0 +1,50 @@ +import {Runtime} from '@atlaspack/plugin'; +import {urlJoin} from '@atlaspack/utils'; + +export default new Runtime({ + apply({bundle, bundleGraph}) { + if (bundle.env.context !== 'service-worker') { + return []; + } + + let asset = bundle.traverse((node, _, actions) => { + if ( + node.type === 'dependency' && + node.value.specifier === '@atlaspack/service-worker' && + !bundleGraph.isDependencySkipped(node.value) + ) { + actions.stop(); + return bundleGraph.getResolvedAsset(node.value, bundle); + } + }); + + if (!asset) { + return []; + } + + let manifest: Array = []; + bundleGraph.traverseBundles((b) => { + if (b.bundleBehavior === 'inline' || b.id === bundle.id) { + return; + } + + manifest.push(urlJoin(b.target.publicUrl, b.name)); + }); + + let code = `import {_register} from '@atlaspack/service-worker'; +const manifest = ${JSON.stringify(manifest)}; +const version = ${JSON.stringify(bundle.hashReference)}; +_register(manifest, version); +`; + + return [ + { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + filePath: asset.filePath, + code, + isEntry: true, + env: {sourceType: 'module'}, + }, + ]; + }, +}) as Runtime; diff --git a/packages/runtimes/webextension/package.json b/packages/runtimes/webextension/package.json index ee49a4ddd..a66331e93 100644 --- a/packages/runtimes/webextension/package.json +++ b/packages/runtimes/webextension/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/WebExtensionRuntime.js", - "source": "src/WebExtensionRuntime.js", + "types": "src/WebExtensionRuntime.ts", + "source": "src/WebExtensionRuntime.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/runtimes/webextension/src/WebExtensionRuntime.js b/packages/runtimes/webextension/src/WebExtensionRuntime.js deleted file mode 100644 index e37c1f0f0..000000000 --- a/packages/runtimes/webextension/src/WebExtensionRuntime.js +++ /dev/null @@ -1,82 +0,0 @@ -// @flow strict-local - -import {Runtime} from '@atlaspack/plugin'; -import {replaceURLReferences} from '@atlaspack/utils'; -import nullthrows from 'nullthrows'; -import fs from 'fs'; -import path from 'path'; - -const AUTORELOAD_BG = fs.readFileSync( - path.join(__dirname, 'autoreload-bg.js'), - 'utf8', -); - -export default (new Runtime({ - loadConfig({config}) { - config.invalidateOnBuild(); - }, - async apply({bundle, bundleGraph, options}) { - if (!bundle.env.isBrowser() || bundle.env.isWorklet()) { - return; - } - - if (bundle.getMainEntry()?.meta.webextEntry === true) { - // Hack to bust packager cache when any descendants update - const descendants = []; - bundleGraph.traverseBundles(b => { - descendants.push(b.id); - }, bundle); - return { - filePath: __filename, - code: JSON.stringify(descendants), - isEntry: true, - }; - } else if (options.hmrOptions && bundle.type == 'js') { - const manifest = bundleGraph - .getBundles() - .find(b => b.getMainEntry()?.meta.webextEntry === true); - const entry = manifest?.getMainEntry(); - const insertDep = entry?.meta.webextBGInsert; - if (!manifest || !entry || insertDep == null) return; - const insertBundle = bundleGraph.getReferencedBundle( - nullthrows(entry?.getDependencies().find(dep => dep.id === insertDep)), - nullthrows(manifest), - ); - let firstInsertableBundle; - bundleGraph.traverseBundles((b, _, actions) => { - if (b.type == 'js') { - firstInsertableBundle = b; - actions.stop(); - } - }, insertBundle); - - // Add autoreload - if (bundle === firstInsertableBundle) { - return [ - { - filePath: __filename, - code: AUTORELOAD_BG, - isEntry: true, - }, - { - filePath: __filename, - // cache bust on non-asset manifest.json changes - code: `JSON.parse(${JSON.stringify( - JSON.stringify( - JSON.parse( - replaceURLReferences({ - bundle: manifest, - bundleGraph, - contents: await entry.getCode(), - getReplacement: () => '', - }).contents, - ), - ), - )})`, - isEntry: true, - }, - ]; - } - } - }, -}): Runtime); diff --git a/packages/runtimes/webextension/src/WebExtensionRuntime.ts b/packages/runtimes/webextension/src/WebExtensionRuntime.ts new file mode 100644 index 000000000..745bdd77f --- /dev/null +++ b/packages/runtimes/webextension/src/WebExtensionRuntime.ts @@ -0,0 +1,84 @@ +import {Runtime} from '@atlaspack/plugin'; +import {replaceURLReferences} from '@atlaspack/utils'; +import nullthrows from 'nullthrows'; +import fs from 'fs'; +import path from 'path'; + +const AUTORELOAD_BG = fs.readFileSync( + path.join(__dirname, 'autoreload-bg.js'), + 'utf8', +); + +export default new Runtime({ + loadConfig({config}) { + config.invalidateOnBuild(); + }, + async apply({bundle, bundleGraph, options}) { + if (!bundle.env.isBrowser() || bundle.env.isWorklet()) { + return; + } + + if (bundle.getMainEntry()?.meta.webextEntry === true) { + // Hack to bust packager cache when any descendants update + const descendants: Array = []; + bundleGraph.traverseBundles((b) => { + descendants.push(b.id); + }, bundle); + return { + filePath: __filename, + code: JSON.stringify(descendants), + isEntry: true, + }; + } else if (options.hmrOptions && bundle.type == 'js') { + const manifest = bundleGraph + .getBundles() + .find((b) => b.getMainEntry()?.meta.webextEntry === true); + const entry = manifest?.getMainEntry(); + const insertDep = entry?.meta.webextBGInsert; + if (!manifest || !entry || insertDep == null) return; + const insertBundle = bundleGraph.getReferencedBundle( + nullthrows( + entry + ?.getDependencies() + .find((dep: Dependency) => dep.id === insertDep), + ), + nullthrows(manifest), + ); + let firstInsertableBundle; + bundleGraph.traverseBundles((b, _, actions) => { + if (b.type == 'js') { + firstInsertableBundle = b; + actions.stop(); + } + }, insertBundle); + + // Add autoreload + if (bundle === firstInsertableBundle) { + return [ + { + filePath: __filename, + code: AUTORELOAD_BG, + isEntry: true, + }, + { + filePath: __filename, + // cache bust on non-asset manifest.json changes + code: `JSON.parse(${JSON.stringify( + JSON.stringify( + JSON.parse( + replaceURLReferences({ + bundle: manifest, + bundleGraph, + contents: await entry.getCode(), + getReplacement: () => '', + }).contents, + ), + ), + )})`, + isEntry: true, + }, + ]; + } + } + }, +}) as Runtime; diff --git a/packages/runtimes/webextension/src/autoreload-bg.js b/packages/runtimes/webextension/src/autoreload-bg.js index f53ad2e96..5558dd495 100644 --- a/packages/runtimes/webextension/src/autoreload-bg.js +++ b/packages/runtimes/webextension/src/autoreload-bg.js @@ -8,7 +8,7 @@ let promisify = (...args) => { if (typeof browser === 'undefined') { return new Promise((resolve, reject) => - obj[fn](...args, res => + obj[fn](...args, (res) => env.runtime.lastError ? reject(env.runtime.lastError) : resolve(res), ), ); @@ -21,9 +21,9 @@ let messageTab = promisify(env.tabs, 'sendMessage'); env.runtime.reload = () => { queryTabs({}) - .then(tabs => { + .then((tabs) => { return Promise.all( - tabs.map(tab => { + tabs.map((tab) => { if (tab.id === avoidID) return; return messageTab(tab.id, { __parcel_hmr_reload__: true, diff --git a/packages/transformers/babel/package.json b/packages/transformers/babel/package.json index eefe20e0d..81e20d37c 100644 --- a/packages/transformers/babel/package.json +++ b/packages/transformers/babel/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/BabelTransformer.js", - "source": "src/BabelTransformer.js", + "types": "src/BabelTransformer.ts", + "source": "src/BabelTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/babel/src/BabelTransformer.js b/packages/transformers/babel/src/BabelTransformer.js deleted file mode 100644 index b779c31aa..000000000 --- a/packages/transformers/babel/src/BabelTransformer.js +++ /dev/null @@ -1,102 +0,0 @@ -// @flow strict-local - -import {babelErrorEnhancer} from './babelErrorUtils'; -import {Transformer} from '@atlaspack/plugin'; -import {relativeUrl} from '@atlaspack/utils'; -import SourceMap from '@parcel/source-map'; -import semver from 'semver'; -import babel7 from './babel7'; -import {load} from './config'; - -export default (new Transformer({ - loadConfig({config, options, logger}) { - return load(config, options, logger); - }, - - canReuseAST({ast}) { - return ast.type === 'babel' && semver.satisfies(ast.version, '^7.0.0'); - }, - - async transform({asset, config, logger, options, tracer}) { - try { - if (config?.config) { - if ( - asset.meta.babelPlugins != null && - Array.isArray(asset.meta.babelPlugins) - ) { - await babel7({ - asset, - options, - logger, - babelOptions: config, - additionalPlugins: asset.meta.babelPlugins, - tracer, - }); - } else { - await babel7({ - asset, - options, - logger, - babelOptions: config, - tracer, - }); - } - } - - return [asset]; - } catch (e) { - throw await babelErrorEnhancer(e, asset); - } - }, - - async generate({asset, ast, options}) { - let originalSourceMap = await asset.getMap(); - let sourceFileName: string = relativeUrl( - options.projectRoot, - asset.filePath, - ); - - const babelCorePath = await options.packageManager.resolve( - '@babel/core', - asset.filePath, - { - range: '^7.12.0', - saveDev: true, - shouldAutoInstall: options.shouldAutoInstall, - }, - ); - - const {default: generate} = await options.packageManager.require( - '@babel/generator', - babelCorePath.resolved, - ); - - let {code, rawMappings} = generate(ast.program, { - sourceFileName, - sourceMaps: !!asset.env.sourceMap, - comments: true, - }); - - let map = new SourceMap(options.projectRoot); - if (rawMappings) { - map.addIndexedMappings(rawMappings); - } - - if (originalSourceMap) { - // The babel AST already contains the correct mappings, but not the source contents. - // We need to copy over the source contents from the original map. - let sourcesContent = originalSourceMap.getSourcesContentMap(); - for (let filePath in sourcesContent) { - let content = sourcesContent[filePath]; - if (content != null) { - map.setSourceContent(filePath, content); - } - } - } - - return { - content: code, - map, - }; - }, -}): Transformer); diff --git a/packages/transformers/babel/src/BabelTransformer.ts b/packages/transformers/babel/src/BabelTransformer.ts new file mode 100644 index 000000000..fc8773856 --- /dev/null +++ b/packages/transformers/babel/src/BabelTransformer.ts @@ -0,0 +1,102 @@ +import {babelErrorEnhancer} from './babelErrorUtils'; +import {Transformer} from '@atlaspack/plugin'; +import {relativeUrl} from '@atlaspack/utils'; +import SourceMap from '@parcel/source-map'; +import semver from 'semver'; +import babel7 from './babel7'; +import {load} from './config'; + +export default new Transformer({ + loadConfig({config, options, logger}) { + return load(config, options, logger); + }, + + canReuseAST({ast}) { + return ast.type === 'babel' && semver.satisfies(ast.version, '^7.0.0'); + }, + + async transform({asset, config, logger, options, tracer}) { + try { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + if (config?.config) { + if ( + asset.meta.babelPlugins != null && + Array.isArray(asset.meta.babelPlugins) + ) { + await babel7({ + asset, + options, + logger, + babelOptions: config, + additionalPlugins: asset.meta.babelPlugins, + tracer, + }); + } else { + await babel7({ + asset, + options, + logger, + babelOptions: config, + tracer, + }); + } + } + + return [asset]; + } catch (e: any) { + throw await babelErrorEnhancer(e, asset); + } + }, + + async generate({asset, ast, options}) { + let originalSourceMap = await asset.getMap(); + let sourceFileName: string = relativeUrl( + options.projectRoot, + asset.filePath, + ); + + const babelCorePath = await options.packageManager.resolve( + '@babel/core', + asset.filePath, + { + range: '^7.12.0', + saveDev: true, + shouldAutoInstall: options.shouldAutoInstall, + }, + ); + + const {default: generate} = await options.packageManager.require( + '@babel/generator', + babelCorePath.resolved, + ); + + let {code, rawMappings} = generate(ast.program, { + sourceFileName, + sourceMaps: !!asset.env.sourceMap, + comments: true, + }); + + let map = new SourceMap(options.projectRoot); + if (rawMappings) { + map.addIndexedMappings(rawMappings); + } + + if (originalSourceMap) { + // The babel AST already contains the correct mappings, but not the source contents. + // We need to copy over the source contents from the original map. + // @ts-expect-error - TS2339 - Property 'getSourcesContentMap' does not exist on type 'SourceMap'. + let sourcesContent = originalSourceMap.getSourcesContentMap(); + for (let filePath in sourcesContent) { + let content = sourcesContent[filePath]; + if (content != null) { + map.setSourceContent(filePath, content); + } + } + } + + return { + content: code, + map, + }; + }, +}) as Transformer; diff --git a/packages/transformers/babel/src/babel7.js b/packages/transformers/babel/src/babel7.js deleted file mode 100644 index 01b435a65..000000000 --- a/packages/transformers/babel/src/babel7.js +++ /dev/null @@ -1,144 +0,0 @@ -// @flow - -import type { - MutableAsset, - AST, - PluginOptions, - PluginTracer, - PluginLogger, -} from '@atlaspack/types'; -import typeof * as BabelCore from '@babel/core'; - -import invariant from 'assert'; -import path from 'path'; -import {md} from '@atlaspack/diagnostic'; -import {relativeUrl} from '@atlaspack/utils'; -import {remapAstLocations} from './remapAstLocations'; - -import packageJson from '../package.json'; - -const transformerVersion: mixed = packageJson.version; -invariant(typeof transformerVersion === 'string'); - -type Babel7TransformOptions = {| - asset: MutableAsset, - options: PluginOptions, - logger: PluginLogger, - babelOptions: any, - additionalPlugins?: Array, - tracer: PluginTracer, -|}; - -export default async function babel7( - opts: Babel7TransformOptions, -): Promise { - let {asset, options, babelOptions, additionalPlugins = [], tracer} = opts; - const babelCore: BabelCore = await options.packageManager.require( - '@babel/core', - asset.filePath, - { - range: '^7.12.0', - saveDev: true, - shouldAutoInstall: options.shouldAutoInstall, - }, - ); - - let config = { - ...babelOptions.config, - plugins: additionalPlugins.concat(babelOptions.config.plugins), - code: false, - ast: true, - filename: asset.filePath, - babelrc: false, - configFile: false, - parserOpts: { - ...babelOptions.config.parserOpts, - sourceFilename: relativeUrl(options.projectRoot, asset.filePath), - allowReturnOutsideFunction: true, - strictMode: false, - sourceType: 'module', - plugins: [ - ...(babelOptions.config.parserOpts?.plugins ?? []), - ...(babelOptions.syntaxPlugins ?? []), - // Applied by preset-env - 'classProperties', - 'classPrivateProperties', - 'classPrivateMethods', - 'exportDefaultFrom', - // 'topLevelAwait' - ], - }, - caller: { - name: 'parcel', - version: transformerVersion, - targets: JSON.stringify(babelOptions.targets), - outputFormat: asset.env.outputFormat, - }, - }; - - if (tracer.enabled) { - config.wrapPluginVisitorMethod = ( - key: string, - nodeType: string, - fn: Function, - ) => { - return function () { - let pluginKey = key; - if (pluginKey.startsWith(options.projectRoot)) { - pluginKey = path.relative(options.projectRoot, pluginKey); - } - const measurement = tracer.createMeasurement( - pluginKey, - nodeType, - path.relative(options.projectRoot, asset.filePath), - ); - fn.apply(this, arguments); - measurement && measurement.end(); - }; - }; - } - - let ast = await asset.getAST(); - let res; - if (ast) { - res = await babelCore.transformFromAstAsync( - ast.program, - asset.isASTDirty() ? undefined : await asset.getCode(), - config, - ); - } else { - res = await babelCore.transformAsync(await asset.getCode(), config); - if (res.ast) { - let map = await asset.getMap(); - if (map) { - remapAstLocations(babelCore.types, res.ast, map); - } - } - if (res.externalDependencies) { - for (let f of res.externalDependencies) { - if (!path.isAbsolute(f)) { - opts.logger.warn({ - message: md`Ignoring non-absolute Babel external dependency: ${f}`, - hints: [ - 'Please report this to the corresponding Babel plugin and/or to Parcel.', - ], - }); - } else { - if (await options.inputFS.exists(f)) { - asset.invalidateOnFileChange(f); - } else { - asset.invalidateOnFileCreate({filePath: f}); - } - } - } - } - } - - if (res.ast) { - asset.setAST({ - type: 'babel', - version: '7.0.0', - program: res.ast, - }); - } -} diff --git a/packages/transformers/babel/src/babel7.ts b/packages/transformers/babel/src/babel7.ts new file mode 100644 index 000000000..ca4d8ff5f --- /dev/null +++ b/packages/transformers/babel/src/babel7.ts @@ -0,0 +1,147 @@ +import type { + MutableAsset, + AST, + PluginOptions, + PluginTracer, + PluginLogger, +} from '@atlaspack/types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module '@babel/core'. '/home/ubuntu/parcel/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type. +import * as BabelCore from '@babel/core'; + +import invariant from 'assert'; +import path from 'path'; +import {md} from '@atlaspack/diagnostic'; +import {relativeUrl} from '@atlaspack/utils'; +import {remapAstLocations} from './remapAstLocations'; + +// @ts-expect-error - TS2732 - Cannot find module '../package.json'. Consider using '--resolveJsonModule' to import module with '.json' extension. +import packageJson from '../package.json'; + +const transformerVersion: unknown = packageJson.version; +invariant(typeof transformerVersion === 'string'); + +type Babel7TransformOptions = { + asset: MutableAsset; + options: PluginOptions; + logger: PluginLogger; + babelOptions: any; + additionalPlugins?: Array; + tracer: PluginTracer; +}; + +export default async function babel7( + opts: Babel7TransformOptions, + // @ts-expect-error - TS2355 - A function whose declared type is neither 'void' nor 'any' must return a value. +): Promise { + let {asset, options, babelOptions, additionalPlugins = [], tracer} = opts; + const babelCore: BabelCore = await options.packageManager.require( + '@babel/core', + asset.filePath, + { + range: '^7.12.0', + saveDev: true, + shouldAutoInstall: options.shouldAutoInstall, + }, + ); + + let config = { + ...babelOptions.config, + plugins: additionalPlugins.concat(babelOptions.config.plugins), + code: false, + ast: true, + filename: asset.filePath, + babelrc: false, + configFile: false, + parserOpts: { + ...babelOptions.config.parserOpts, + sourceFilename: relativeUrl(options.projectRoot, asset.filePath), + allowReturnOutsideFunction: true, + strictMode: false, + sourceType: 'module', + plugins: [ + ...(babelOptions.config.parserOpts?.plugins ?? []), + ...(babelOptions.syntaxPlugins ?? []), + // Applied by preset-env + 'classProperties', + 'classPrivateProperties', + 'classPrivateMethods', + 'exportDefaultFrom', + // 'topLevelAwait' + ], + }, + caller: { + name: 'parcel', + version: transformerVersion, + targets: JSON.stringify(babelOptions.targets), + outputFormat: asset.env.outputFormat, + }, + }; + + if (tracer.enabled) { + config.wrapPluginVisitorMethod = ( + key: string, + nodeType: string, + fn: any, + ) => { + return function () { + let pluginKey = key; + if (pluginKey.startsWith(options.projectRoot)) { + pluginKey = path.relative(options.projectRoot, pluginKey); + } + const measurement = tracer.createMeasurement( + pluginKey, + nodeType, + path.relative(options.projectRoot, asset.filePath), + ); + // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation. + fn.apply(this, arguments); + measurement && measurement.end(); + }; + }; + } + + let ast = await asset.getAST(); + let res; + if (ast) { + res = await babelCore.transformFromAstAsync( + ast.program, + asset.isASTDirty() ? undefined : await asset.getCode(), + config, + ); + } else { + res = await babelCore.transformAsync(await asset.getCode(), config); + if (res.ast) { + let map = await asset.getMap(); + if (map) { + remapAstLocations(babelCore.types, res.ast, map); + } + } + if (res.externalDependencies) { + for (let f of res.externalDependencies) { + if (!path.isAbsolute(f)) { + opts.logger.warn({ + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message: md`Ignoring non-absolute Babel external dependency: ${f}`, + hints: [ + 'Please report this to the corresponding Babel plugin and/or to Parcel.', + ], + }); + } else { + if (await options.inputFS.exists(f)) { + asset.invalidateOnFileChange(f); + } else { + asset.invalidateOnFileCreate({filePath: f}); + } + } + } + } + } + + if (res.ast) { + asset.setAST({ + type: 'babel', + version: '7.0.0', + program: res.ast, + }); + } +} diff --git a/packages/transformers/babel/src/babelErrorUtils.js b/packages/transformers/babel/src/babelErrorUtils.js deleted file mode 100644 index e83074799..000000000 --- a/packages/transformers/babel/src/babelErrorUtils.js +++ /dev/null @@ -1,30 +0,0 @@ -// @flow -import type {BaseAsset} from '@atlaspack/types'; - -export type BabelError = Error & { - loc?: { - line: number, - column: number, - ... - }, - source?: string, - filePath?: string, - ... -}; - -export async function babelErrorEnhancer( - error: BabelError, - asset: BaseAsset, -): Promise { - if (error.loc) { - let start = error.message.startsWith(asset.filePath) - ? asset.filePath.length + 1 - : 0; - error.message = error.message.slice(start).split('\n')[0].trim(); - } - - error.source = await asset.getCode(); - error.filePath = asset.filePath; - - return error; -} diff --git a/packages/transformers/babel/src/babelErrorUtils.ts b/packages/transformers/babel/src/babelErrorUtils.ts new file mode 100644 index 000000000..340b60d3d --- /dev/null +++ b/packages/transformers/babel/src/babelErrorUtils.ts @@ -0,0 +1,27 @@ +import type {BaseAsset} from '@atlaspack/types'; + +export type BabelError = Error & { + loc?: { + line: number; + column: number; + }; + source?: string; + filePath?: string; +}; + +export async function babelErrorEnhancer( + error: BabelError, + asset: BaseAsset, +): Promise { + if (error.loc) { + let start = error.message.startsWith(asset.filePath) + ? asset.filePath.length + 1 + : 0; + error.message = error.message.slice(start).split('\n')[0].trim(); + } + + error.source = await asset.getCode(); + error.filePath = asset.filePath; + + return error; +} diff --git a/packages/transformers/babel/src/config.js b/packages/transformers/babel/src/config.js deleted file mode 100644 index 937e42e51..000000000 --- a/packages/transformers/babel/src/config.js +++ /dev/null @@ -1,413 +0,0 @@ -// @flow - -import type {Config, PluginOptions, PluginLogger} from '@atlaspack/types'; -import typeof * as BabelCore from '@babel/core'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import type {BabelConfig} from './types'; - -import json5 from 'json5'; -import path from 'path'; -import {hashObject, relativePath, resolveConfig} from '@atlaspack/utils'; -import {md, generateJSONCodeHighlights} from '@atlaspack/diagnostic'; -import {BABEL_CORE_RANGE} from './constants'; - -import isJSX from './jsx'; -import getFlowOptions from './flow'; -import {enginesToBabelTargets} from './utils'; - -const TYPESCRIPT_EXTNAME_RE = /\.tsx?$/; -const JS_EXTNAME_RE = /^\.(js|cjs|mjs)$/; -const BABEL_CONFIG_FILENAMES = [ - '.babelrc', - '.babelrc.js', - '.babelrc.json', - '.babelrc.cjs', - '.babelrc.mjs', - '.babelignore', - 'babel.config.js', - 'babel.config.json', - 'babel.config.mjs', - 'babel.config.cjs', -]; - -type BabelConfigResult = {| - internal: boolean, - config: BabelConfig, - targets?: mixed, - syntaxPlugins?: mixed, -|}; - -export async function load( - config: Config, - options: PluginOptions, - logger: PluginLogger, -): Promise { - // Don't transpile inside node_modules - if (!config.isSource) { - return; - } - - // Invalidate when any babel config file is added. - for (let fileName of BABEL_CONFIG_FILENAMES) { - config.invalidateOnFileCreate({ - fileName, - aboveFilePath: config.searchPath, - }); - } - - // Do nothing if we cannot resolve any babel config filenames. Checking using our own - // config resolution (which is cached) is much faster than relying on babel. - if ( - !(await resolveConfig( - options.inputFS, - config.searchPath, - BABEL_CONFIG_FILENAMES, - options.projectRoot, - )) - ) { - return buildDefaultBabelConfig(options, config); - } - - const babelCore: BabelCore = await options.packageManager.require( - '@babel/core', - config.searchPath, - { - range: BABEL_CORE_RANGE, - saveDev: true, - shouldAutoInstall: options.shouldAutoInstall, - }, - ); - config.addDevDependency({ - specifier: '@babel/core', - resolveFrom: config.searchPath, - range: BABEL_CORE_RANGE, - }); - - config.invalidateOnEnvChange('BABEL_ENV'); - config.invalidateOnEnvChange('NODE_ENV'); - let babelOptions = { - filename: config.searchPath, - cwd: options.projectRoot, - envName: - options.env.BABEL_ENV ?? - options.env.NODE_ENV ?? - (options.mode === 'production' || options.mode === 'development' - ? options.mode - : null) ?? - 'development', - showIgnoredFiles: true, - }; - - let partialConfig: ?{| - [string]: any, - |} = await babelCore.loadPartialConfigAsync(babelOptions); - - let addIncludedFile = file => { - if (JS_EXTNAME_RE.test(path.extname(file))) { - // We need to invalidate on startup in case the config is non-static, - // e.g. uses unknown environment variables, reads from the filesystem, etc. - logger.warn({ - message: `It looks like you're using a JavaScript Babel config file. This means the config cannot be watched for changes, and Babel transformations cannot be cached. You'll need to restart Parcel for changes to this config to take effect. Try using a ${ - path.basename(file, path.extname(file)) + '.json' - } file instead.`, - }); - config.invalidateOnStartup(); - - // But also add the config as a dev dependency so we can at least attempt invalidation in watch mode. - config.addDevDependency({ - specifier: relativePath(options.projectRoot, file), - resolveFrom: path.join(options.projectRoot, 'index'), - // Also invalidate @babel/core when the config or a dependency updates. - // This ensures that the caches in @babel/core are also invalidated. - additionalInvalidations: [ - { - specifier: '@babel/core', - resolveFrom: config.searchPath, - range: BABEL_CORE_RANGE, - }, - ], - }); - } else { - config.invalidateOnFileChange(file); - } - }; - - let warnOldVersion = () => { - logger.warn({ - message: - 'You are using an old version of @babel/core which does not support the necessary features for Parcel to cache and watch babel config files safely. You may need to restart Parcel for config changes to take effect. Please upgrade to @babel/core 7.12.0 or later to resolve this issue.', - }); - config.invalidateOnStartup(); - }; - - // Old versions of @babel/core return null from loadPartialConfig when the file should explicitly not be run through babel (ignore/exclude) - if (partialConfig == null) { - warnOldVersion(); - return; - } - - if (partialConfig.files == null) { - // If the files property is missing, we're on an old version of @babel/core. - // We need to invalidate on startup because we can't properly track dependencies. - if (partialConfig.hasFilesystemConfig()) { - warnOldVersion(); - - if (typeof partialConfig.babelrcPath === 'string') { - addIncludedFile(partialConfig.babelrcPath); - } - - if (typeof partialConfig.configPath === 'string') { - addIncludedFile(partialConfig.configPath); - } - } - } else { - for (let file of partialConfig.files) { - addIncludedFile(file); - } - } - - if ( - partialConfig.fileHandling != null && - partialConfig.fileHandling !== 'transpile' - ) { - return; - } else if (partialConfig.hasFilesystemConfig()) { - // Determine what syntax plugins we need to enable - let syntaxPlugins = []; - if (TYPESCRIPT_EXTNAME_RE.test(config.searchPath)) { - syntaxPlugins.push('typescript'); - if (config.searchPath.endsWith('.tsx')) { - syntaxPlugins.push('jsx'); - } - } else if (await isJSX(options, config)) { - syntaxPlugins.push('jsx'); - } - - // If the config has plugins loaded with require(), or inline plugins in the config, - // we can't cache the result of the compilation because we don't know where they came from. - if (hasRequire(partialConfig.options)) { - logger.warn({ - message: - 'It looks like you are using `require` to configure Babel plugins or presets. This means Babel transformations cannot be cached and will run on each build. Please use strings to configure Babel instead.', - }); - - config.setCacheKey(JSON.stringify(Date.now())); - config.invalidateOnStartup(); - } else { - await warnOnRedundantPlugins(options.inputFS, partialConfig, logger); - definePluginDependencies(config, partialConfig.options, options); - config.setCacheKey(hashObject(partialConfig.options)); - } - - return { - internal: false, - config: partialConfig.options, - targets: enginesToBabelTargets(config.env), - syntaxPlugins, - }; - } else { - return buildDefaultBabelConfig(options, config); - } -} - -async function buildDefaultBabelConfig( - options: PluginOptions, - config: Config, -): Promise { - // If this is a .ts or .tsx file, we don't need to enable flow. - if (TYPESCRIPT_EXTNAME_RE.test(config.searchPath)) { - return; - } - - // Detect flow. If not enabled, babel doesn't need to run at all. - let babelOptions = await getFlowOptions(config, options); - if (babelOptions == null) { - return; - } - - // When flow is enabled, we may also need to enable JSX so it parses properly. - let syntaxPlugins = []; - if (await isJSX(options, config)) { - syntaxPlugins.push('jsx'); - } - - definePluginDependencies(config, babelOptions, options); - return { - internal: true, - config: babelOptions, - syntaxPlugins, - }; -} - -function hasRequire(options) { - let configItems = [...options.presets, ...options.plugins]; - return configItems.some(item => !item.file); -} - -function definePluginDependencies(config, babelConfig: ?BabelConfig, options) { - if (babelConfig == null) { - return; - } - - let configItems = [ - ...(babelConfig.presets || []), - ...(babelConfig.plugins || []), - ]; - for (let configItem of configItems) { - // FIXME: this uses a relative path from the project root rather than resolving - // from the config location because configItem.file.request can be a shorthand - // rather than a full package name. - config.addDevDependency({ - specifier: relativePath(options.projectRoot, configItem.file.resolved), - resolveFrom: path.join(options.projectRoot, 'index'), - // Also invalidate @babel/core when the plugin or a dependency updates. - // This ensures that the caches in @babel/core are also invalidated. - additionalInvalidations: [ - { - specifier: '@babel/core', - resolveFrom: config.searchPath, - range: BABEL_CORE_RANGE, - }, - ], - }); - } -} - -const redundantPresets = new Set([ - '@babel/preset-env', - '@babel/preset-react', - '@babel/preset-typescript', - '@atlaspack/babel-preset-env', -]); - -async function warnOnRedundantPlugins(fs, babelConfig, logger) { - if (babelConfig == null) { - return; - } - - let configPath = babelConfig.config ?? babelConfig.babelrc; - if (!configPath) { - return; - } - - let presets = babelConfig.options.presets || []; - let plugins = babelConfig.options.plugins || []; - let foundRedundantPresets = new Set(); - - let filteredPresets = presets.filter(preset => { - if (redundantPresets.has(preset.file.request)) { - foundRedundantPresets.add(preset.file.request); - return false; - } - - return true; - }); - - let filePath = path.relative(process.cwd(), configPath); - let diagnostics: Array = []; - - if ( - filteredPresets.length === 0 && - foundRedundantPresets.size > 0 && - plugins.length === 0 - ) { - diagnostics.push({ - message: md`Parcel includes transpilation by default. Babel config __${filePath}__ contains only redundant presets. Deleting it may significantly improve build performance.`, - codeFrames: [ - { - filePath: configPath, - codeHighlights: await getCodeHighlights( - fs, - configPath, - foundRedundantPresets, - ), - }, - ], - hints: [md`Delete __${filePath}__`], - documentationURL: - 'https://parceljs.org/languages/javascript/#default-presets', - }); - } else if (foundRedundantPresets.size > 0) { - diagnostics.push({ - message: md`Parcel includes transpilation by default. Babel config __${filePath}__ includes the following redundant presets: ${[ - ...foundRedundantPresets, - ].map(p => - md.underline(p), - )}. Removing these may improve build performance.`, - codeFrames: [ - { - filePath: configPath, - codeHighlights: await getCodeHighlights( - fs, - configPath, - foundRedundantPresets, - ), - }, - ], - hints: [md`Remove the above presets from __${filePath}__`], - documentationURL: - 'https://parceljs.org/languages/javascript/#default-presets', - }); - } - - if (foundRedundantPresets.has('@babel/preset-env')) { - diagnostics.push({ - message: - "@babel/preset-env does not support Parcel's targets, which will likely result in unnecessary transpilation and larger bundle sizes.", - codeFrames: [ - { - filePath: babelConfig.config ?? babelConfig.babelrc, - codeHighlights: await getCodeHighlights( - fs, - babelConfig.config ?? babelConfig.babelrc, - new Set(['@babel/preset-env']), - ), - }, - ], - hints: [ - `Either remove __@babel/preset-env__ to use Parcel's builtin transpilation, or replace with __@atlaspack/babel-preset-env__`, - ], - documentationURL: - 'https://parceljs.org/languages/javascript/#custom-plugins', - }); - } - - if (diagnostics.length > 0) { - logger.warn(diagnostics); - } -} - -async function getCodeHighlights(fs, filePath, redundantPresets) { - let ext = path.extname(filePath); - if (ext !== '.js' && ext !== '.cjs' && ext !== '.mjs') { - let contents = await fs.readFile(filePath, 'utf8'); - let json = json5.parse(contents); - - let presets = json.presets || []; - let pointers = []; - for (let i = 0; i < presets.length; i++) { - if (Array.isArray(presets[i]) && redundantPresets.has(presets[i][0])) { - pointers.push({type: 'value', key: `/presets/${i}/0`}); - } else if (redundantPresets.has(presets[i])) { - pointers.push({type: 'value', key: `/presets/${i}`}); - } - } - - if (pointers.length > 0) { - return generateJSONCodeHighlights(contents, pointers); - } - } - - return [ - { - start: { - line: 1, - column: 1, - }, - end: { - line: 1, - column: 1, - }, - }, - ]; -} diff --git a/packages/transformers/babel/src/config.ts b/packages/transformers/babel/src/config.ts new file mode 100644 index 000000000..e7313f617 --- /dev/null +++ b/packages/transformers/babel/src/config.ts @@ -0,0 +1,440 @@ +import type {Config, PluginOptions, PluginLogger} from '@atlaspack/types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module '@babel/core'. '/home/ubuntu/parcel/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type. +import * as BabelCore from '@babel/core'; +import type {Diagnostic} from '@atlaspack/diagnostic'; +import type {BabelConfig} from './types'; + +import json5 from 'json5'; +import path from 'path'; +import {hashObject, relativePath, resolveConfig} from '@atlaspack/utils'; +import {md, generateJSONCodeHighlights} from '@atlaspack/diagnostic'; +import {BABEL_CORE_RANGE} from './constants'; + +import isJSX from './jsx'; +import getFlowOptions from './flow'; +import {enginesToBabelTargets} from './utils'; + +const TYPESCRIPT_EXTNAME_RE = /\.tsx?$/; +const JS_EXTNAME_RE = /^\.(js|cjs|mjs)$/; +const BABEL_CONFIG_FILENAMES = [ + '.babelrc', + '.babelrc.js', + '.babelrc.json', + '.babelrc.cjs', + '.babelrc.mjs', + '.babelignore', + 'babel.config.js', + 'babel.config.json', + 'babel.config.mjs', + 'babel.config.cjs', +]; + +type BabelConfigResult = { + internal: boolean; + config: BabelConfig; + targets?: unknown; + syntaxPlugins?: unknown; +}; + +export async function load( + config: Config, + options: PluginOptions, + logger: PluginLogger, +): Promise { + // Don't transpile inside node_modules + if (!config.isSource) { + return; + } + + // Invalidate when any babel config file is added. + for (let fileName of BABEL_CONFIG_FILENAMES) { + config.invalidateOnFileCreate({ + fileName, + aboveFilePath: config.searchPath, + }); + } + + // Do nothing if we cannot resolve any babel config filenames. Checking using our own + // config resolution (which is cached) is much faster than relying on babel. + if ( + !(await resolveConfig( + options.inputFS, + config.searchPath, + BABEL_CONFIG_FILENAMES, + options.projectRoot, + )) + ) { + return buildDefaultBabelConfig(options, config); + } + + const babelCore: BabelCore = await options.packageManager.require( + '@babel/core', + config.searchPath, + { + range: BABEL_CORE_RANGE, + saveDev: true, + shouldAutoInstall: options.shouldAutoInstall, + }, + ); + config.addDevDependency({ + specifier: '@babel/core', + resolveFrom: config.searchPath, + range: BABEL_CORE_RANGE, + }); + + config.invalidateOnEnvChange('BABEL_ENV'); + config.invalidateOnEnvChange('NODE_ENV'); + let babelOptions = { + filename: config.searchPath, + cwd: options.projectRoot, + envName: + options.env.BABEL_ENV ?? + options.env.NODE_ENV ?? + (options.mode === 'production' || options.mode === 'development' + ? options.mode + : null) ?? + 'development', + showIgnoredFiles: true, + }; + + let partialConfig: + | { + [key: string]: any; + } + | null + | undefined = await babelCore.loadPartialConfigAsync(babelOptions); + + let addIncludedFile = (file: string) => { + if (JS_EXTNAME_RE.test(path.extname(file))) { + // We need to invalidate on startup in case the config is non-static, + // e.g. uses unknown environment variables, reads from the filesystem, etc. + logger.warn({ + message: `It looks like you're using a JavaScript Babel config file. This means the config cannot be watched for changes, and Babel transformations cannot be cached. You'll need to restart Parcel for changes to this config to take effect. Try using a ${ + path.basename(file, path.extname(file)) + '.json' + } file instead.`, + }); + config.invalidateOnStartup(); + + // But also add the config as a dev dependency so we can at least attempt invalidation in watch mode. + config.addDevDependency({ + specifier: relativePath(options.projectRoot, file), + resolveFrom: path.join(options.projectRoot, 'index'), + // Also invalidate @babel/core when the config or a dependency updates. + // This ensures that the caches in @babel/core are also invalidated. + additionalInvalidations: [ + { + specifier: '@babel/core', + resolveFrom: config.searchPath, + range: BABEL_CORE_RANGE, + }, + ], + }); + } else { + config.invalidateOnFileChange(file); + } + }; + + let warnOldVersion = () => { + logger.warn({ + message: + 'You are using an old version of @babel/core which does not support the necessary features for Parcel to cache and watch babel config files safely. You may need to restart Parcel for config changes to take effect. Please upgrade to @babel/core 7.12.0 or later to resolve this issue.', + }); + config.invalidateOnStartup(); + }; + + // Old versions of @babel/core return null from loadPartialConfig when the file should explicitly not be run through babel (ignore/exclude) + if (partialConfig == null) { + warnOldVersion(); + return; + } + + if (partialConfig.files == null) { + // If the files property is missing, we're on an old version of @babel/core. + // We need to invalidate on startup because we can't properly track dependencies. + if (partialConfig.hasFilesystemConfig()) { + warnOldVersion(); + + if (typeof partialConfig.babelrcPath === 'string') { + addIncludedFile(partialConfig.babelrcPath); + } + + if (typeof partialConfig.configPath === 'string') { + addIncludedFile(partialConfig.configPath); + } + } + } else { + for (let file of partialConfig.files) { + addIncludedFile(file); + } + } + + if ( + partialConfig.fileHandling != null && + partialConfig.fileHandling !== 'transpile' + ) { + return; + } else if (partialConfig.hasFilesystemConfig()) { + // Determine what syntax plugins we need to enable + let syntaxPlugins: Array = []; + if (TYPESCRIPT_EXTNAME_RE.test(config.searchPath)) { + syntaxPlugins.push('typescript'); + if (config.searchPath.endsWith('.tsx')) { + syntaxPlugins.push('jsx'); + } + } else if (await isJSX(options, config)) { + syntaxPlugins.push('jsx'); + } + + // If the config has plugins loaded with require(), or inline plugins in the config, + // we can't cache the result of the compilation because we don't know where they came from. + if (hasRequire(partialConfig.options)) { + logger.warn({ + message: + 'It looks like you are using `require` to configure Babel plugins or presets. This means Babel transformations cannot be cached and will run on each build. Please use strings to configure Babel instead.', + }); + + config.setCacheKey(JSON.stringify(Date.now())); + config.invalidateOnStartup(); + } else { + // @ts-expect-error - TS2345 - Argument of type 'import("/home/ubuntu/parcel/packages/core/types-internal/src/FileSystem").FileSystem' is not assignable to parameter of type 'FileSystem'. + await warnOnRedundantPlugins(options.inputFS, partialConfig, logger); + definePluginDependencies(config, partialConfig.options, options); + config.setCacheKey(hashObject(partialConfig.options)); + } + + return { + internal: false, + config: partialConfig.options, + targets: enginesToBabelTargets(config.env), + syntaxPlugins, + }; + } else { + return buildDefaultBabelConfig(options, config); + } +} + +async function buildDefaultBabelConfig( + options: PluginOptions, + config: Config, +): Promise { + // If this is a .ts or .tsx file, we don't need to enable flow. + if (TYPESCRIPT_EXTNAME_RE.test(config.searchPath)) { + return; + } + + // Detect flow. If not enabled, babel doesn't need to run at all. + let babelOptions = await getFlowOptions(config, options); + if (babelOptions == null) { + return; + } + + // When flow is enabled, we may also need to enable JSX so it parses properly. + let syntaxPlugins: Array = []; + if (await isJSX(options, config)) { + syntaxPlugins.push('jsx'); + } + + definePluginDependencies(config, babelOptions, options); + return { + internal: true, + config: babelOptions, + syntaxPlugins, + }; +} + +function hasRequire(options: any) { + let configItems = [...options.presets, ...options.plugins]; + return configItems.some((item) => !item.file); +} + +function definePluginDependencies( + config: Config, + babelConfig: BabelConfig | null | undefined, + options: PluginOptions, +) { + if (babelConfig == null) { + return; + } + + let configItems = [ + ...(babelConfig.presets || []), + ...(babelConfig.plugins || []), + ]; + for (let configItem of configItems) { + // FIXME: this uses a relative path from the project root rather than resolving + // from the config location because configItem.file.request can be a shorthand + // rather than a full package name. + config.addDevDependency({ + specifier: relativePath(options.projectRoot, configItem.file.resolved), + resolveFrom: path.join(options.projectRoot, 'index'), + // Also invalidate @babel/core when the plugin or a dependency updates. + // This ensures that the caches in @babel/core are also invalidated. + additionalInvalidations: [ + { + specifier: '@babel/core', + resolveFrom: config.searchPath, + range: BABEL_CORE_RANGE, + }, + ], + }); + } +} + +const redundantPresets = new Set([ + '@babel/preset-env', + '@babel/preset-react', + '@babel/preset-typescript', + '@atlaspack/babel-preset-env', +]); + +async function warnOnRedundantPlugins( + fs: FileSystem, + babelConfig: { + [key: string]: any; + }, + logger: PluginLogger, +) { + if (babelConfig == null) { + return; + } + + let configPath = babelConfig.config ?? babelConfig.babelrc; + if (!configPath) { + return; + } + + let presets = babelConfig.options.presets || []; + let plugins = babelConfig.options.plugins || []; + let foundRedundantPresets = new Set(); + + // @ts-expect-error - TS7006 - Parameter 'preset' implicitly has an 'any' type. + let filteredPresets = presets.filter((preset) => { + if (redundantPresets.has(preset.file.request)) { + foundRedundantPresets.add(preset.file.request); + return false; + } + + return true; + }); + + let filePath = path.relative(process.cwd(), configPath); + let diagnostics: Array = []; + + if ( + filteredPresets.length === 0 && + foundRedundantPresets.size > 0 && + plugins.length === 0 + ) { + diagnostics.push({ + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message: md`Parcel includes transpilation by default. Babel config __${filePath}__ contains only redundant presets. Deleting it may significantly improve build performance.`, + codeFrames: [ + { + filePath: configPath, + codeHighlights: await getCodeHighlights( + fs, + configPath, + foundRedundantPresets, + ), + }, + ], + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + hints: [md`Delete __${filePath}__`], + documentationURL: + 'https://parceljs.org/languages/javascript/#default-presets', + }); + } else if (foundRedundantPresets.size > 0) { + diagnostics.push({ + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + message: md`Parcel includes transpilation by default. Babel config __${filePath}__ includes the following redundant presets: ${[ + ...foundRedundantPresets, + ].map((p) => + md.underline(p), + )}. Removing these may improve build performance.`, + codeFrames: [ + { + filePath: configPath, + codeHighlights: await getCodeHighlights( + fs, + configPath, + foundRedundantPresets, + ), + }, + ], + // @ts-expect-error - TS2345 - Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'string[]'. + hints: [md`Remove the above presets from __${filePath}__`], + documentationURL: + 'https://parceljs.org/languages/javascript/#default-presets', + }); + } + + if (foundRedundantPresets.has('@babel/preset-env')) { + diagnostics.push({ + message: + "@babel/preset-env does not support Parcel's targets, which will likely result in unnecessary transpilation and larger bundle sizes.", + codeFrames: [ + { + filePath: babelConfig.config ?? babelConfig.babelrc, + codeHighlights: await getCodeHighlights( + fs, + babelConfig.config ?? babelConfig.babelrc, + new Set(['@babel/preset-env']), + ), + }, + ], + hints: [ + `Either remove __@babel/preset-env__ to use Parcel's builtin transpilation, or replace with __@atlaspack/babel-preset-env__`, + ], + documentationURL: + 'https://parceljs.org/languages/javascript/#custom-plugins', + }); + } + + if (diagnostics.length > 0) { + logger.warn(diagnostics); + } +} + +async function getCodeHighlights( + fs: FileSystem, + filePath: any, + redundantPresets: Set, +) { + let ext = path.extname(filePath); + if (ext !== '.js' && ext !== '.cjs' && ext !== '.mjs') { + // @ts-expect-error - TS2339 - Property 'readFile' does not exist on type 'FileSystem'. + let contents = await fs.readFile(filePath, 'utf8'); + let json = json5.parse(contents); + + let presets = json.presets || []; + let pointers: Array<{ + key: string; + message?: string; + type?: 'key' | 'value' | null | undefined; + }> = []; + for (let i = 0; i < presets.length; i++) { + if (Array.isArray(presets[i]) && redundantPresets.has(presets[i][0])) { + pointers.push({type: 'value', key: `/presets/${i}/0`}); + } else if (redundantPresets.has(presets[i])) { + pointers.push({type: 'value', key: `/presets/${i}`}); + } + } + + if (pointers.length > 0) { + return generateJSONCodeHighlights(contents, pointers); + } + } + + return [ + { + start: { + line: 1, + column: 1, + }, + end: { + line: 1, + column: 1, + }, + }, + ]; +} diff --git a/packages/transformers/babel/src/constants.js b/packages/transformers/babel/src/constants.js deleted file mode 100644 index 60a923111..000000000 --- a/packages/transformers/babel/src/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow strict-local - -export const BABEL_CORE_RANGE = '^7.12.0'; diff --git a/packages/transformers/babel/src/constants.ts b/packages/transformers/babel/src/constants.ts new file mode 100644 index 000000000..6029cfdc6 --- /dev/null +++ b/packages/transformers/babel/src/constants.ts @@ -0,0 +1 @@ +export const BABEL_CORE_RANGE = '^7.12.0'; diff --git a/packages/transformers/babel/src/flow.js b/packages/transformers/babel/src/flow.js deleted file mode 100644 index c163ee99c..000000000 --- a/packages/transformers/babel/src/flow.js +++ /dev/null @@ -1,66 +0,0 @@ -// @flow - -import type {Config, PluginOptions, PackageJSON} from '@atlaspack/types'; -import type {BabelConfig} from './types'; -import typeof * as BabelCore from '@babel/core'; - -import {BABEL_CORE_RANGE} from './constants'; -import path from 'path'; - -/** - * Generates a babel config for stripping away Flow types. - */ -export default async function getFlowOptions( - config: Config, - options: PluginOptions, -): Promise { - if (!config.isSource) { - return null; - } - - // Only add flow plugin if `flow-bin` is listed as a dependency in the root package.json - let conf = await config.getConfigFrom( - options.projectRoot + '/index', - ['package.json'], - ); - let pkg = conf?.contents; - if ( - !pkg || - (!(pkg.dependencies && pkg.dependencies['flow-bin']) && - !(pkg.devDependencies && pkg.devDependencies['flow-bin'])) - ) { - return null; - } - - const babelCore: BabelCore = await options.packageManager.require( - '@babel/core', - config.searchPath, - { - range: BABEL_CORE_RANGE, - saveDev: true, - shouldAutoInstall: options.shouldAutoInstall, - }, - ); - - await options.packageManager.require( - '@babel/plugin-transform-flow-strip-types', - config.searchPath, - { - range: '^7.0.0', - saveDev: true, - shouldAutoInstall: options.shouldAutoInstall, - }, - ); - - return { - plugins: [ - babelCore.createConfigItem( - ['@babel/plugin-transform-flow-strip-types', {requireDirective: true}], - { - type: 'plugin', - dirname: path.dirname(config.searchPath), - }, - ), - ], - }; -} diff --git a/packages/transformers/babel/src/flow.ts b/packages/transformers/babel/src/flow.ts new file mode 100644 index 000000000..273f0a322 --- /dev/null +++ b/packages/transformers/babel/src/flow.ts @@ -0,0 +1,65 @@ +import type {Config, PluginOptions, PackageJSON} from '@atlaspack/types'; +import type {BabelConfig} from './types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module '@babel/core'. '/home/ubuntu/parcel/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type. +import * as BabelCore from '@babel/core'; + +import {BABEL_CORE_RANGE} from './constants'; +import path from 'path'; + +/** + * Generates a babel config for stripping away Flow types. + */ +export default async function getFlowOptions( + config: Config, + options: PluginOptions, +): Promise { + if (!config.isSource) { + return null; + } + + // Only add flow plugin if `flow-bin` is listed as a dependency in the root package.json + let conf = await config.getConfigFrom( + options.projectRoot + '/index', + ['package.json'], + ); + let pkg = conf?.contents; + if ( + !pkg || + (!(pkg.dependencies && pkg.dependencies['flow-bin']) && + !(pkg.devDependencies && pkg.devDependencies['flow-bin'])) + ) { + return null; + } + + const babelCore: BabelCore = await options.packageManager.require( + '@babel/core', + config.searchPath, + { + range: BABEL_CORE_RANGE, + saveDev: true, + shouldAutoInstall: options.shouldAutoInstall, + }, + ); + + await options.packageManager.require( + '@babel/plugin-transform-flow-strip-types', + config.searchPath, + { + range: '^7.0.0', + saveDev: true, + shouldAutoInstall: options.shouldAutoInstall, + }, + ); + + return { + plugins: [ + babelCore.createConfigItem( + ['@babel/plugin-transform-flow-strip-types', {requireDirective: true}], + { + type: 'plugin', + dirname: path.dirname(config.searchPath), + }, + ), + ], + }; +} diff --git a/packages/transformers/babel/src/jsx.js b/packages/transformers/babel/src/jsx.js deleted file mode 100644 index 05872833f..000000000 --- a/packages/transformers/babel/src/jsx.js +++ /dev/null @@ -1,40 +0,0 @@ -// @flow strict-local - -import type {Config, PluginOptions} from '@atlaspack/types'; - -import path from 'path'; - -const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']); -const JSX_LIBRARIES = ['react', 'preact', 'nervejs', 'hyperapp']; - -/** - * Returns whether an asset is likely JSX. Attempts to detect react or react-like libraries - * along with - */ -export default async function isJSX( - options: PluginOptions, - config: Config, -): Promise { - if (!config.isSource) { - return false; - } - - if (JSX_EXTENSIONS.has(path.extname(config.searchPath))) { - return true; - } - - let pkg = await config.getPackage(); - if (pkg?.alias && pkg.alias['react']) { - // e.g.: `{ alias: { "react": "preact/compat" } }` - return true; - } else { - // Find a dependency that implies JSX syntax. - return JSX_LIBRARIES.some( - libName => - pkg && - ((pkg.dependencies && pkg.dependencies[libName]) || - (pkg.devDependencies && pkg.devDependencies[libName]) || - (pkg.peerDependencies && pkg.peerDependencies[libName])), - ); - } -} diff --git a/packages/transformers/babel/src/jsx.ts b/packages/transformers/babel/src/jsx.ts new file mode 100644 index 000000000..3ff7ac611 --- /dev/null +++ b/packages/transformers/babel/src/jsx.ts @@ -0,0 +1,38 @@ +import type {Config, PluginOptions} from '@atlaspack/types'; + +import path from 'path'; + +const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']); +const JSX_LIBRARIES = ['react', 'preact', 'nervejs', 'hyperapp']; + +/** + * Returns whether an asset is likely JSX. Attempts to detect react or react-like libraries + * along with + */ +export default async function isJSX( + options: PluginOptions, + config: Config, +): Promise { + if (!config.isSource) { + return false; + } + + if (JSX_EXTENSIONS.has(path.extname(config.searchPath))) { + return true; + } + + let pkg = await config.getPackage(); + if (pkg?.alias && pkg.alias['react']) { + // e.g.: `{ alias: { "react": "preact/compat" } }` + return true; + } else { + // Find a dependency that implies JSX syntax. + return JSX_LIBRARIES.some( + (libName) => + pkg && + ((pkg.dependencies && pkg.dependencies[libName]) || + (pkg.devDependencies && pkg.devDependencies[libName]) || + (pkg.peerDependencies && pkg.peerDependencies[libName])), + ); + } +} diff --git a/packages/transformers/babel/src/remapAstLocations.js b/packages/transformers/babel/src/remapAstLocations.js deleted file mode 100644 index db650f293..000000000 --- a/packages/transformers/babel/src/remapAstLocations.js +++ /dev/null @@ -1,70 +0,0 @@ -// @flow strict-local - -import type {File as BabelNodeFile} from '@babel/types'; -import type SourceMap from '@parcel/source-map'; -import type {Node} from '@babel/types'; -import typeof * as BabelTypes from '@babel/types'; - -export function remapAstLocations( - t: BabelTypes, - ast: BabelNodeFile, - map: SourceMap, -) { - // remap ast to original mappings - // This improves sourcemap accuracy and fixes sourcemaps when scope-hoisting - traverseAll(t, ast.program, node => { - if (node.loc) { - if (node.loc?.start) { - let mapping = map.findClosestMapping( - node.loc.start.line, - node.loc.start.column, - ); - - if (mapping?.original) { - // $FlowFixMe - node.loc.start.line = mapping.original.line; - // $FlowFixMe - node.loc.start.column = mapping.original.column; - - // $FlowFixMe - let length = node.loc.end.column - node.loc.start.column; - - // $FlowFixMe - node.loc.end.line = mapping.original.line; - // $FlowFixMe - node.loc.end.column = mapping.original.column + length; - - // $FlowFixMe - node.loc.filename = mapping.source; - } else { - // Maintain null mappings? - node.loc = null; - } - } - } - }); -} - -function traverseAll( - t: BabelTypes, - node: Node, - visitor: (node: Node) => void, -): void { - if (!node) { - return; - } - - visitor(node); - - for (let key of t.VISITOR_KEYS[node.type] || []) { - // $FlowFixMe - let subNode: Node | Array = node[key]; - if (Array.isArray(subNode)) { - for (let i = 0; i < subNode.length; i++) { - traverseAll(t, subNode[i], visitor); - } - } else { - traverseAll(t, subNode, visitor); - } - } -} diff --git a/packages/transformers/babel/src/remapAstLocations.ts b/packages/transformers/babel/src/remapAstLocations.ts new file mode 100644 index 000000000..5dd46d2e5 --- /dev/null +++ b/packages/transformers/babel/src/remapAstLocations.ts @@ -0,0 +1,65 @@ +import type {File as BabelNodeFile} from '@babel/types'; +import type SourceMap from '@parcel/source-map'; +import type {Node} from '@babel/types'; +import * as BabelTypes from '@babel/types'; + +export function remapAstLocations( + // @ts-expect-error - TS2709 - Cannot use namespace 'BabelTypes' as a type. + t: BabelTypes, + ast: BabelNodeFile, + map: SourceMap, +) { + // remap ast to original mappings + // This improves sourcemap accuracy and fixes sourcemaps when scope-hoisting + traverseAll(t, ast.program, (node) => { + if (node.loc) { + if (node.loc?.start) { + let mapping = map.findClosestMapping( + node.loc.start.line, + node.loc.start.column, + ); + + if (mapping?.original) { + node.loc.start.line = mapping.original.line; + node.loc.start.column = mapping.original.column; + + let length = node.loc.end.column - node.loc.start.column; + + node.loc.end.line = mapping.original.line; + node.loc.end.column = mapping.original.column + length; + + // @ts-expect-error - TS2322 - Type 'string | undefined' is not assignable to type 'string'. + node.loc.filename = mapping.source; + } else { + // Maintain null mappings? + node.loc = null; + } + } + } + }); +} + +function traverseAll( + // @ts-expect-error - TS2709 - Cannot use namespace 'BabelTypes' as a type. + t: BabelTypes, + node: Node, + visitor: (node: Node) => void, +): void { + if (!node) { + return; + } + + visitor(node); + + for (let key of t.VISITOR_KEYS[node.type] || []) { + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Node'. + let subNode: Node | Array = node[key]; + if (Array.isArray(subNode)) { + for (let i = 0; i < subNode.length; i++) { + traverseAll(t, subNode[i], visitor); + } + } else { + traverseAll(t, subNode, visitor); + } + } +} diff --git a/packages/transformers/babel/src/types.js b/packages/transformers/babel/src/types.js deleted file mode 100644 index 36a03d167..000000000 --- a/packages/transformers/babel/src/types.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow - -export type BabelConfig = {| - plugins?: Array, - presets?: Array, -|}; diff --git a/packages/transformers/babel/src/types.ts b/packages/transformers/babel/src/types.ts new file mode 100644 index 000000000..13ebd3edd --- /dev/null +++ b/packages/transformers/babel/src/types.ts @@ -0,0 +1,4 @@ +export type BabelConfig = { + plugins?: Array; + presets?: Array; +}; diff --git a/packages/transformers/babel/src/utils.js b/packages/transformers/babel/src/utils.js deleted file mode 100644 index 21795c33e..000000000 --- a/packages/transformers/babel/src/utils.js +++ /dev/null @@ -1,86 +0,0 @@ -// @flow - -import type {Environment} from '@atlaspack/types'; -import type {Targets as BabelTargets} from '@babel/preset-env'; - -import invariant from 'assert'; -import semver from 'semver'; - -// Copied from @babel/helper-compilation-targets/lib/options.js -const TargetNames = { - node: 'node', - chrome: 'chrome', - opera: 'opera', - edge: 'edge', - firefox: 'firefox', - safari: 'safari', - ie: 'ie', - ios: 'ios', - android: 'android', - electron: 'electron', - samsung: 'samsung', - rhino: 'rhino', -}; - -// List of browsers to exclude when the esmodule target is specified. -// Based on https://caniuse.com/#feat=es6-module -const ESMODULE_BROWSERS = [ - 'not ie <= 11', - 'not edge < 16', - 'not firefox < 60', - 'not chrome < 61', - 'not safari < 11', - 'not opera < 48', - 'not ios_saf < 11', - 'not op_mini all', - 'not android < 76', - 'not blackberry > 0', - 'not op_mob > 0', - 'not and_chr < 76', - 'not and_ff < 68', - 'not ie_mob > 0', - 'not and_uc > 0', - 'not samsung < 8.2', - 'not and_qq > 0', - 'not baidu > 0', - 'not kaios > 0', -]; - -export function enginesToBabelTargets(env: Environment): BabelTargets { - // "Targets" is the name @babel/preset-env uses for what Parcel calls engines. - // This should not be confused with Parcel's own targets. - // Unlike Parcel's engines, @babel/preset-env expects to work with minimum - // versions, not semver ranges, of its targets. - let targets = {}; - for (let engineName of Object.keys(env.engines)) { - let engineValue = env.engines[engineName]; - - // if the engineValue is a string, it might be a semver range. Use the minimum - // possible version instead. - if (engineName === 'browsers') { - targets[engineName] = engineValue; - } else { - invariant(typeof engineValue === 'string'); - if (!TargetNames.hasOwnProperty(engineName)) continue; - let minVersion = semver.minVersion(engineValue)?.toString(); - targets[engineName] = minVersion ?? engineValue; - } - } - - if (env.outputFormat === 'esmodule' && env.isBrowser()) { - // If there is already a browsers target, add a blacklist to exclude - // instead of using babel's esmodules target. This allows specifying - // a newer set of browsers than the baseline esmodule support list. - // See https://github.com/babel/babel/issues/8809. - if (targets.browsers) { - let browsers = Array.isArray(targets.browsers) - ? targets.browsers - : [targets.browsers]; - targets.browsers = [...browsers, ...ESMODULE_BROWSERS]; - } else { - targets.esmodules = true; - } - } - - return targets; -} diff --git a/packages/transformers/babel/src/utils.ts b/packages/transformers/babel/src/utils.ts new file mode 100644 index 000000000..6451e96f6 --- /dev/null +++ b/packages/transformers/babel/src/utils.ts @@ -0,0 +1,86 @@ +import type {Environment} from '@atlaspack/types'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module '@babel/preset-env'. '/home/ubuntu/parcel/node_modules/@babel/preset-env/lib/index.js' implicitly has an 'any' type. +import type {Targets as BabelTargets} from '@babel/preset-env'; + +import invariant from 'assert'; +import semver from 'semver'; + +// Copied from @babel/helper-compilation-targets/lib/options.js +const TargetNames = { + node: 'node', + chrome: 'chrome', + opera: 'opera', + edge: 'edge', + firefox: 'firefox', + safari: 'safari', + ie: 'ie', + ios: 'ios', + android: 'android', + electron: 'electron', + samsung: 'samsung', + rhino: 'rhino', +} as const; + +// List of browsers to exclude when the esmodule target is specified. +// Based on https://caniuse.com/#feat=es6-module +const ESMODULE_BROWSERS = [ + 'not ie <= 11', + 'not edge < 16', + 'not firefox < 60', + 'not chrome < 61', + 'not safari < 11', + 'not opera < 48', + 'not ios_saf < 11', + 'not op_mini all', + 'not android < 76', + 'not blackberry > 0', + 'not op_mob > 0', + 'not and_chr < 76', + 'not and_ff < 68', + 'not ie_mob > 0', + 'not and_uc > 0', + 'not samsung < 8.2', + 'not and_qq > 0', + 'not baidu > 0', + 'not kaios > 0', +]; + +export function enginesToBabelTargets(env: Environment): BabelTargets { + // "Targets" is the name @babel/preset-env uses for what Parcel calls engines. + // This should not be confused with Parcel's own targets. + // Unlike Parcel's engines, @babel/preset-env expects to work with minimum + // versions, not semver ranges, of its targets. + let targets: Record = {}; + for (let engineName of Object.keys(env.engines)) { + // @ts-expect-error - TS7053 - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Engines'. + let engineValue = env.engines[engineName]; + + // if the engineValue is a string, it might be a semver range. Use the minimum + // possible version instead. + if (engineName === 'browsers') { + targets[engineName] = engineValue; + } else { + invariant(typeof engineValue === 'string'); + if (!TargetNames.hasOwnProperty(engineName)) continue; + let minVersion = semver.minVersion(engineValue)?.toString(); + targets[engineName] = minVersion ?? engineValue; + } + } + + if (env.outputFormat === 'esmodule' && env.isBrowser()) { + // If there is already a browsers target, add a blacklist to exclude + // instead of using babel's esmodules target. This allows specifying + // a newer set of browsers than the baseline esmodule support list. + // See https://github.com/babel/babel/issues/8809. + if (targets.browsers) { + let browsers = Array.isArray(targets.browsers) + ? targets.browsers + : [targets.browsers]; + targets.browsers = [...browsers, ...ESMODULE_BROWSERS]; + } else { + targets.esmodules = true; + } + } + + return targets; +} diff --git a/packages/transformers/css/package.json b/packages/transformers/css/package.json index 78504d83f..5e0d2e741 100644 --- a/packages/transformers/css/package.json +++ b/packages/transformers/css/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/CSSTransformer.js", - "source": "src/CSSTransformer.js", + "types": "src/CSSTransformer.ts", + "source": "src/CSSTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/css/src/CSSTransformer.js b/packages/transformers/css/src/CSSTransformer.js deleted file mode 100644 index a4987b129..000000000 --- a/packages/transformers/css/src/CSSTransformer.js +++ /dev/null @@ -1,390 +0,0 @@ -// @flow strict-local - -import type {SourceLocation} from '@atlaspack/types'; - -import path from 'path'; -import SourceMap from '@parcel/source-map'; -import {Transformer} from '@atlaspack/plugin'; -import { - remapSourceLocation, - relativePath, - globToRegex, - normalizeSeparators, -} from '@atlaspack/utils'; -import {type SourceLocation as LightningSourceLocation} from 'lightningcss'; -import * as native from 'lightningcss'; -import browserslist from 'browserslist'; -import nullthrows from 'nullthrows'; -import ThrowableDiagnostic, {errorToDiagnostic} from '@atlaspack/diagnostic'; - -const {transform, transformStyleAttribute, browserslistToTargets} = native; - -export default (new Transformer({ - async loadConfig({config, options}) { - let conf = await config.getConfigFrom(options.projectRoot + '/index', [], { - packageKey: '@atlaspack/transformer-css', - }); - let contents = conf?.contents; - if (typeof contents?.cssModules?.include === 'string') { - contents.cssModules.include = [globToRegex(contents.cssModules.include)]; - } else if (Array.isArray(contents?.cssModules?.include)) { - contents.cssModules.include = contents.cssModules.include.map(include => - typeof include === 'string' ? globToRegex(include) : include, - ); - } - if (typeof contents?.cssModules?.exclude === 'string') { - contents.cssModules.exclude = [globToRegex(contents.cssModules.exclude)]; - } else if (Array.isArray(contents?.cssModules?.exclude)) { - contents.cssModules.exclude = contents.cssModules.exclude.map(exclude => - typeof exclude === 'string' ? globToRegex(exclude) : exclude, - ); - } - return contents; - }, - async transform({asset, config, options, logger}) { - // Normalize the asset's environment so that properties that only affect JS don't cause CSS to be duplicated. - // For example, with ESModule and CommonJS targets, only a single shared CSS bundle should be produced. - let env = asset.env; - asset.setEnvironment({ - context: 'browser', - engines: { - browsers: asset.env.engines.browsers, - }, - shouldOptimize: asset.env.shouldOptimize, - shouldScopeHoist: asset.env.shouldScopeHoist, - sourceMap: asset.env.sourceMap, - }); - - let [code, originalMap] = await Promise.all([ - asset.getBuffer(), - asset.getMap(), - // $FlowFixMe native.default is the init function only when bundled for the browser build - process.browser && native.default(), - ]); - - let targets = getTargets(asset.env.engines.browsers); - let res; - try { - if (asset.meta.type === 'attr') { - res = transformStyleAttribute({ - code, - analyzeDependencies: true, - errorRecovery: config?.errorRecovery || false, - targets, - }); - } else { - let cssModules = false; - if ( - asset.meta.type !== 'tag' && - asset.meta.cssModulesCompiled == null - ) { - let cssModulesConfig = config?.cssModules; - let isCSSModule = /\.module\./.test(asset.filePath); - if (asset.isSource) { - let projectRootPath = path.relative( - options.projectRoot, - asset.filePath, - ); - if (typeof cssModulesConfig === 'boolean') { - isCSSModule = true; - } else if (cssModulesConfig?.include) { - isCSSModule = cssModulesConfig.include.some(include => - include.test(projectRootPath), - ); - } else if (cssModulesConfig?.global) { - isCSSModule = true; - } - - if ( - cssModulesConfig?.exclude?.some(exclude => - exclude.test(projectRootPath), - ) - ) { - isCSSModule = false; - } - } - - if (isCSSModule) { - if (cssModulesConfig?.dashedIdents && !asset.isSource) { - cssModulesConfig.dashedIdents = false; - } - - cssModules = cssModulesConfig ?? true; - } - } - - res = transform({ - filename: normalizeSeparators( - path.relative(options.projectRoot, asset.filePath), - ), - code, - cssModules, - analyzeDependencies: - asset.meta.hasDependencies !== false - ? { - preserveImports: true, - } - : false, - sourceMap: !!asset.env.sourceMap, - drafts: config?.drafts, - pseudoClasses: config?.pseudoClasses, - errorRecovery: config?.errorRecovery || false, - targets, - }); - } - } catch (err) { - err.filePath = asset.filePath; - let diagnostic = errorToDiagnostic(err, { - origin: '@atlaspack/transformer-css', - }); - if (err.data?.type === 'AmbiguousUrlInCustomProperty' && err.data.url) { - let p = - '/' + - relativePath( - options.projectRoot, - path.resolve(path.dirname(asset.filePath), err.data.url), - false, - ); - diagnostic[0].hints = [`Replace with: url(${p})`]; - diagnostic[0].documentationURL = - 'https://parceljs.org/languages/css/#url()'; - } - - throw new ThrowableDiagnostic({ - diagnostic, - }); - } - - if (res.warnings) { - for (let warning of res.warnings) { - logger.warn({ - message: warning.message, - codeFrames: [ - { - filePath: asset.filePath, - codeHighlights: [ - { - start: { - line: warning.loc.line, - column: warning.loc.column + 1, - }, - end: { - line: warning.loc.line, - column: warning.loc.column + 1, - }, - }, - ], - }, - ], - }); - } - } - - if (res.map != null) { - let vlqMap = JSON.parse(Buffer.from(res.map).toString()); - let map = new SourceMap(options.projectRoot); - map.addVLQMap(vlqMap); - - if (originalMap) { - map.extends(originalMap); - } - - asset.setMap(map); - } - - if (res.dependencies) { - for (let dep of res.dependencies) { - let loc = convertLoc(dep.loc); - if (originalMap) { - loc = remapSourceLocation(loc, originalMap); - } - - if (dep.type === 'import' && !res.exports) { - asset.addDependency({ - specifier: dep.url, - specifierType: 'url', - loc, - packageConditions: ['style'], - meta: { - // For the glob resolver to distinguish between `@import` and other URL dependencies. - isCSSImport: true, - media: dep.media, - placeholder: dep.placeholder, - }, - }); - } else if (dep.type === 'url') { - asset.addURLDependency(dep.url, { - loc, - meta: { - placeholder: dep.placeholder, - }, - }); - } - } - } - - let assets = [asset]; - let buffer = Buffer.from(res.code); - - if (res.exports != null) { - let exports = res.exports; - asset.symbols.ensure(); - asset.symbols.set('default', 'default'); - - let dependencies = new Map(); - let locals = new Map(); - let c = 0; - let depjs = ''; - let js = ''; - let cssImports = ''; - - let jsDeps = []; - - for (let key in exports) { - locals.set(exports[key].name, key); - } - - asset.uniqueKey ??= asset.id; - - let seen = new Set(); - let add = key => { - if (seen.has(key)) { - return; - } - seen.add(key); - - let e = exports[key]; - let s = `module.exports[${JSON.stringify(key)}] = \`${e.name}`; - - for (let ref of e.composes) { - s += ' '; - if (ref.type === 'local') { - let exported = nullthrows(locals.get(ref.name)); - add(exported); - s += '${' + `module.exports[${JSON.stringify(exported)}]` + '}'; - asset.addDependency({ - specifier: nullthrows(asset.uniqueKey), - specifierType: 'esm', - symbols: new Map([ - [exported, {local: ref.name, isWeak: false, loc: null}], - ]), - }); - } else if (ref.type === 'global') { - s += ref.name; - } else if (ref.type === 'dependency') { - let d = dependencies.get(ref.specifier); - if (d == null) { - d = `dep_${c++}`; - depjs += `import * as ${d} from ${JSON.stringify( - ref.specifier, - )};\n`; - dependencies.set(ref.specifier, d); - cssImports += `@import "${ref.specifier}";\n`; - asset.addDependency({ - specifier: ref.specifier, - specifierType: 'esm', - packageConditions: ['style'], - }); - } - s += '${' + `${d}[${JSON.stringify(ref.name)}]` + '}'; - } - } - - s += '`;\n'; - - // If the export is referenced internally (e.g. used @keyframes), add a self-reference - // to the JS so the symbol is retained during tree-shaking. - if (e.isReferenced) { - s += `module.exports[${JSON.stringify(key)}];\n`; - asset.addDependency({ - specifier: nullthrows(asset.uniqueKey), - specifierType: 'esm', - symbols: new Map([ - [key, {local: exports[key].name, isWeak: false, loc: null}], - ]), - }); - } - - js += s; - }; - - // It's possible that the exports can be ordered differently between builds. - // Sorting by key is safe as the order is irrelevant but needs to be deterministic. - for (let key of Object.keys(exports).sort()) { - asset.symbols.set(key, exports[key].name); - add(key); - } - - if (res.dependencies) { - for (let dep of res.dependencies) { - if (dep.type === 'import') { - // TODO: Figure out how to treeshake this - let d = `dep_$${c++}`; - depjs += `import * as ${d} from ${JSON.stringify(dep.url)};\n`; - js += `for (let key in ${d}) { if (key in module.exports) module.exports[key] += ' ' + ${d}[key]; else module.exports[key] = ${d}[key]; }\n`; - asset.symbols.set('*', '*'); - } - } - } - - if (res.references != null) { - let references = res.references; - for (let symbol in references) { - let reference = references[symbol]; - asset.addDependency({ - specifier: reference.specifier, - specifierType: 'esm', - packageConditions: ['style'], - symbols: new Map([ - [reference.name, {local: symbol, isWeak: false, loc: null}], - ]), - }); - - asset.meta.hasReferences = true; - cssImports += `@import "${reference.specifier}";\n`; - } - } - - assets.push({ - type: 'js', - content: depjs + js, - dependencies: jsDeps, - env, - }); - - // Prepend @import rules for each composes dependency so packager knows where to insert them. - if (cssImports.length > 0) { - buffer = Buffer.concat([Buffer.from(cssImports), buffer]); - } - } - - asset.setBuffer(buffer); - return assets; - }, -}): Transformer); - -let cache = new Map(); - -function getTargets(browsers) { - if (browsers == null) { - return undefined; - } - - let cached = cache.get(browsers); - if (cached != null) { - return cached; - } - - let targets = browserslistToTargets(browserslist(browsers)); - - cache.set(browsers, targets); - return targets; -} - -function convertLoc(loc: LightningSourceLocation): SourceLocation { - return { - filePath: loc.filePath, - start: {line: loc.start.line, column: loc.start.column}, - end: {line: loc.end.line, column: loc.end.column + 1}, - }; -} diff --git a/packages/transformers/css/src/CSSTransformer.ts b/packages/transformers/css/src/CSSTransformer.ts new file mode 100644 index 000000000..cf4d89110 --- /dev/null +++ b/packages/transformers/css/src/CSSTransformer.ts @@ -0,0 +1,415 @@ +import type {SourceLocation} from '@atlaspack/types'; + +import path from 'path'; +import SourceMap from '@parcel/source-map'; +import {Transformer} from '@atlaspack/plugin'; +import { + remapSourceLocation, + relativePath, + globToRegex, + normalizeSeparators, +} from '@atlaspack/utils'; +import {SourceLocation as LightningSourceLocation} from 'lightningcss'; +import * as native from 'lightningcss'; +import browserslist from 'browserslist'; +import nullthrows from 'nullthrows'; +import ThrowableDiagnostic, {errorToDiagnostic} from '@atlaspack/diagnostic'; + +const {transform, transformStyleAttribute, browserslistToTargets} = native; + +export default new Transformer({ + async loadConfig({config, options}) { + let conf = await config.getConfigFrom(options.projectRoot + '/index', [], { + packageKey: '@atlaspack/transformer-css', + }); + let contents = conf?.contents; + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + if (typeof contents?.cssModules?.include === 'string') { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. | TS2571 - Object is of type 'unknown'. + contents.cssModules.include = [globToRegex(contents.cssModules.include)]; + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + } else if (Array.isArray(contents?.cssModules?.include)) { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. | TS2571 - Object is of type 'unknown'. | TS7006 - Parameter 'include' implicitly has an 'any' type. + contents.cssModules.include = contents.cssModules.include.map((include) => + typeof include === 'string' ? globToRegex(include) : include, + ); + } + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + if (typeof contents?.cssModules?.exclude === 'string') { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. | TS2571 - Object is of type 'unknown'. + contents.cssModules.exclude = [globToRegex(contents.cssModules.exclude)]; + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + } else if (Array.isArray(contents?.cssModules?.exclude)) { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. | TS2571 - Object is of type 'unknown'. | TS7006 - Parameter 'exclude' implicitly has an 'any' type. + contents.cssModules.exclude = contents.cssModules.exclude.map((exclude) => + typeof exclude === 'string' ? globToRegex(exclude) : exclude, + ); + } + return contents; + }, + async transform({asset, config, options, logger}) { + // Normalize the asset's environment so that properties that only affect JS don't cause CSS to be duplicated. + // For example, with ESModule and CommonJS targets, only a single shared CSS bundle should be produced. + let env = asset.env; + asset.setEnvironment({ + context: 'browser', + engines: { + browsers: asset.env.engines.browsers, + }, + shouldOptimize: asset.env.shouldOptimize, + shouldScopeHoist: asset.env.shouldScopeHoist, + sourceMap: asset.env.sourceMap, + }); + + let [code, originalMap] = await Promise.all([ + asset.getBuffer(), + asset.getMap(), + // $FlowFixMe native.default is the init function only when bundled for the browser build + // @ts-expect-error - TS2339 - Property 'browser' does not exist on type 'Process'. | TS2339 - Property 'default' does not exist on type 'typeof import("/home/ubuntu/parcel/node_modules/lightningcss/node/index")'. + process.browser && native.default(), + ]); + + let targets = getTargets(asset.env.engines.browsers); + let res; + try { + if (asset.meta.type === 'attr') { + res = transformStyleAttribute({ + code, + analyzeDependencies: true, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + errorRecovery: config?.errorRecovery || false, + targets, + }); + } else { + let cssModules = false; + if ( + asset.meta.type !== 'tag' && + asset.meta.cssModulesCompiled == null + ) { + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + let cssModulesConfig = config?.cssModules; + let isCSSModule = /\.module\./.test(asset.filePath); + if (asset.isSource) { + let projectRootPath = path.relative( + options.projectRoot, + asset.filePath, + ); + if (typeof cssModulesConfig === 'boolean') { + isCSSModule = true; + } else if (cssModulesConfig?.include) { + // @ts-expect-error - TS7006 - Parameter 'include' implicitly has an 'any' type. + isCSSModule = cssModulesConfig.include.some((include) => + include.test(projectRootPath), + ); + } else if (cssModulesConfig?.global) { + isCSSModule = true; + } + + if ( + cssModulesConfig?.exclude?.some((exclude: any) => + exclude.test(projectRootPath), + ) + ) { + isCSSModule = false; + } + } + + if (isCSSModule) { + if (cssModulesConfig?.dashedIdents && !asset.isSource) { + cssModulesConfig.dashedIdents = false; + } + + cssModules = cssModulesConfig ?? true; + } + } + + res = transform({ + filename: normalizeSeparators( + path.relative(options.projectRoot, asset.filePath), + ), + code, + cssModules, + analyzeDependencies: + asset.meta.hasDependencies !== false + ? { + preserveImports: true, + } + : false, + sourceMap: !!asset.env.sourceMap, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + drafts: config?.drafts, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + pseudoClasses: config?.pseudoClasses, + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + errorRecovery: config?.errorRecovery || false, + targets, + }); + } + } catch (err: any) { + err.filePath = asset.filePath; + let diagnostic = errorToDiagnostic(err, { + origin: '@atlaspack/transformer-css', + }); + if (err.data?.type === 'AmbiguousUrlInCustomProperty' && err.data.url) { + let p = + '/' + + relativePath( + options.projectRoot, + path.resolve(path.dirname(asset.filePath), err.data.url), + false, + ); + diagnostic[0].hints = [`Replace with: url(${p})`]; + diagnostic[0].documentationURL = + 'https://parceljs.org/languages/css/#url()'; + } + + throw new ThrowableDiagnostic({ + diagnostic, + }); + } + + if (res.warnings) { + for (let warning of res.warnings) { + logger.warn({ + message: warning.message, + codeFrames: [ + { + filePath: asset.filePath, + codeHighlights: [ + { + start: { + line: warning.loc.line, + column: warning.loc.column + 1, + }, + end: { + line: warning.loc.line, + column: warning.loc.column + 1, + }, + }, + ], + }, + ], + }); + } + } + + // @ts-expect-error - TS2339 - Property 'map' does not exist on type 'TransformAttributeResult'. + if (res.map != null) { + // @ts-expect-error - TS2339 - Property 'map' does not exist on type 'TransformAttributeResult'. + let vlqMap = JSON.parse(Buffer.from(res.map).toString()); + let map = new SourceMap(options.projectRoot); + map.addVLQMap(vlqMap); + + if (originalMap) { + // @ts-expect-error - TS2345 - Argument of type 'SourceMap' is not assignable to parameter of type 'Buffer'. + map.extends(originalMap); + } + + asset.setMap(map); + } + + if (res.dependencies) { + for (let dep of res.dependencies) { + let loc = convertLoc(dep.loc); + if (originalMap) { + loc = remapSourceLocation(loc, originalMap); + } + + // @ts-expect-error - TS2339 - Property 'exports' does not exist on type 'TransformAttributeResult'. + if (dep.type === 'import' && !res.exports) { + asset.addDependency({ + specifier: dep.url, + specifierType: 'url', + loc, + packageConditions: ['style'], + meta: { + // For the glob resolver to distinguish between `@import` and other URL dependencies. + isCSSImport: true, + media: dep.media, + placeholder: dep.placeholder, + }, + }); + } else if (dep.type === 'url') { + asset.addURLDependency(dep.url, { + loc, + meta: { + placeholder: dep.placeholder, + }, + }); + } + } + } + + let assets = [asset]; + let buffer = Buffer.from(res.code); + + // @ts-expect-error - TS2339 - Property 'exports' does not exist on type 'TransformAttributeResult'. + if (res.exports != null) { + // @ts-expect-error - TS2339 - Property 'exports' does not exist on type 'TransformAttributeResult'. + let exports = res.exports; + asset.symbols.ensure(); + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + asset.symbols.set('default', 'default'); + + let dependencies = new Map(); + let locals = new Map(); + let c = 0; + let depjs = ''; + let js = ''; + let cssImports = ''; + + let jsDeps: Array = []; + + for (let key in exports) { + locals.set(exports[key].name, key); + } + + asset.uniqueKey ??= asset.id; + + let seen = new Set(); + let add = (key: string) => { + if (seen.has(key)) { + return; + } + seen.add(key); + + let e = exports[key]; + let s = `module.exports[${JSON.stringify(key)}] = \`${e.name}`; + + for (let ref of e.composes) { + s += ' '; + if (ref.type === 'local') { + let exported = nullthrows(locals.get(ref.name)); + add(exported); + s += '${' + `module.exports[${JSON.stringify(exported)}]` + '}'; + asset.addDependency({ + specifier: nullthrows(asset.uniqueKey), + specifierType: 'esm', + symbols: new Map([ + [exported, {local: ref.name, isWeak: false, loc: null}], + ]), + }); + } else if (ref.type === 'global') { + s += ref.name; + } else if (ref.type === 'dependency') { + let d = dependencies.get(ref.specifier); + if (d == null) { + d = `dep_${c++}`; + depjs += `import * as ${d} from ${JSON.stringify( + ref.specifier, + )};\n`; + dependencies.set(ref.specifier, d); + cssImports += `@import "${ref.specifier}";\n`; + asset.addDependency({ + specifier: ref.specifier, + specifierType: 'esm', + packageConditions: ['style'], + }); + } + s += '${' + `${d}[${JSON.stringify(ref.name)}]` + '}'; + } + } + + s += '`;\n'; + + // If the export is referenced internally (e.g. used @keyframes), add a self-reference + // to the JS so the symbol is retained during tree-shaking. + if (e.isReferenced) { + s += `module.exports[${JSON.stringify(key)}];\n`; + asset.addDependency({ + specifier: nullthrows(asset.uniqueKey), + specifierType: 'esm', + symbols: new Map([ + [key, {local: exports[key].name, isWeak: false, loc: null}], + ]), + }); + } + + js += s; + }; + + // It's possible that the exports can be ordered differently between builds. + // Sorting by key is safe as the order is irrelevant but needs to be deterministic. + for (let key of Object.keys(exports).sort()) { + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + asset.symbols.set(key, exports[key].name); + add(key); + } + + if (res.dependencies) { + for (let dep of res.dependencies) { + if (dep.type === 'import') { + // TODO: Figure out how to treeshake this + let d = `dep_$${c++}`; + depjs += `import * as ${d} from ${JSON.stringify(dep.url)};\n`; + js += `for (let key in ${d}) { if (key in module.exports) module.exports[key] += ' ' + ${d}[key]; else module.exports[key] = ${d}[key]; }\n`; + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'symbol'. + asset.symbols.set('*', '*'); + } + } + } + + // @ts-expect-error - TS2339 - Property 'references' does not exist on type 'TransformAttributeResult'. + if (res.references != null) { + // @ts-expect-error - TS2339 - Property 'references' does not exist on type 'TransformAttributeResult'. + let references = res.references; + for (let symbol in references) { + let reference = references[symbol]; + asset.addDependency({ + specifier: reference.specifier, + specifierType: 'esm', + packageConditions: ['style'], + symbols: new Map([ + [reference.name, {local: symbol, isWeak: false, loc: null}], + ]), + }); + + asset.meta.hasReferences = true; + cssImports += `@import "${reference.specifier}";\n`; + } + } + + assets.push({ + type: 'js', + // @ts-expect-error - TS2345 - Argument of type '{ type: string; content: string; dependencies: never[]; env: Environment; }' is not assignable to parameter of type 'MutableAsset'. + content: depjs + js, + dependencies: jsDeps, + env, + }); + + // Prepend @import rules for each composes dependency so packager knows where to insert them. + if (cssImports.length > 0) { + buffer = Buffer.concat([Buffer.from(cssImports), buffer]); + } + } + + asset.setBuffer(buffer); + return assets; + }, +}) as Transformer; + +let cache = new Map(); + +function getTargets(browsers: undefined | string | Array) { + if (browsers == null) { + return undefined; + } + + let cached = cache.get(browsers); + if (cached != null) { + return cached; + } + + let targets = browserslistToTargets(browserslist(browsers)); + + cache.set(browsers, targets); + return targets; +} + +function convertLoc(loc: LightningSourceLocation): SourceLocation { + return { + filePath: loc.filePath, + start: {line: loc.start.line, column: loc.start.column}, + end: {line: loc.end.line, column: loc.end.column + 1}, + }; +} diff --git a/packages/transformers/glsl/package.json b/packages/transformers/glsl/package.json index 6616fb8f5..8fb9aac9c 100644 --- a/packages/transformers/glsl/package.json +++ b/packages/transformers/glsl/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/GLSLTransformer.js", - "source": "src/GLSLTransformer.js", + "types": "src/GLSLTransformer.ts", + "source": "src/GLSLTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/glsl/src/GLSLTransformer.js b/packages/transformers/glsl/src/GLSLTransformer.js deleted file mode 100644 index ad6baecbe..000000000 --- a/packages/transformers/glsl/src/GLSLTransformer.js +++ /dev/null @@ -1,51 +0,0 @@ -// @flow -import path from 'path'; -import {promisify} from 'util'; -import {Transformer} from '@atlaspack/plugin'; -import glslifyDeps from 'glslify-deps'; -import glslifyBundle from 'glslify-bundle'; - -export default (new Transformer({ - async transform({asset, resolve}) { - // Parse and collect dependencies with glslify-deps - let cwd = path.dirname(asset.filePath); - let depper = glslifyDeps({ - cwd, - resolve: async (target, opts, next) => { - try { - let filePath = await resolve( - path.join(opts.basedir, 'index.glsl'), - target, - ); - - next(null, filePath); - } catch (err) { - next(err); - } - }, - }); - - let ast = await promisify(depper.inline.bind(depper))( - await asset.getCode(), - cwd, - ); - - collectDependencies(asset, ast); - - // Generate the bundled glsl file - let glsl = await glslifyBundle(ast); - - asset.setCode(`module.exports=${JSON.stringify(glsl)};`); - asset.type = 'js'; - - return [asset]; - }, -}): Transformer); - -function collectDependencies(asset, ast) { - for (let dep of ast) { - if (!dep.entry) { - asset.invalidateOnFileChange(dep.file); - } - } -} diff --git a/packages/transformers/glsl/src/GLSLTransformer.ts b/packages/transformers/glsl/src/GLSLTransformer.ts new file mode 100644 index 000000000..fd7fe5f6c --- /dev/null +++ b/packages/transformers/glsl/src/GLSLTransformer.ts @@ -0,0 +1,53 @@ +import path from 'path'; +import {promisify} from 'util'; +import {Transformer} from '@atlaspack/plugin'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'glslify-deps'. '/home/ubuntu/parcel/node_modules/glslify-deps/index.js' implicitly has an 'any' type. +import glslifyDeps from 'glslify-deps'; +// @ts-expect-error - TS7016 - Could not find a declaration file for module 'glslify-bundle'. '/home/ubuntu/parcel/node_modules/glslify-bundle/index.js' implicitly has an 'any' type. +import glslifyBundle from 'glslify-bundle'; + +export default new Transformer({ + async transform({asset, resolve}) { + // Parse and collect dependencies with glslify-deps + let cwd = path.dirname(asset.filePath); + let depper = glslifyDeps({ + cwd, + // @ts-expect-error - TS7006 - Parameter 'target' implicitly has an 'any' type. | TS7006 - Parameter 'opts' implicitly has an 'any' type. | TS7006 - Parameter 'next' implicitly has an 'any' type. + resolve: async (target, opts, next) => { + try { + let filePath = await resolve( + path.join(opts.basedir, 'index.glsl'), + target, + ); + + next(null, filePath); + } catch (err: any) { + next(err); + } + }, + }); + + let ast = await promisify(depper.inline.bind(depper))( + await asset.getCode(), + cwd, + ); + + collectDependencies(asset, ast); + + // Generate the bundled glsl file + let glsl = await glslifyBundle(ast); + + asset.setCode(`module.exports=${JSON.stringify(glsl)};`); + asset.type = 'js'; + + return [asset]; + }, +}) as Transformer; + +function collectDependencies(asset: MutableAsset, ast: any) { + for (let dep of ast) { + if (!dep.entry) { + asset.invalidateOnFileChange(dep.file); + } + } +} diff --git a/packages/transformers/graphql/package.json b/packages/transformers/graphql/package.json index 67ac9639c..4e8fe834f 100644 --- a/packages/transformers/graphql/package.json +++ b/packages/transformers/graphql/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/GraphQLTransformer.js", - "source": "src/GraphQLTransformer.js", + "types": "src/GraphQLTransformer.ts", + "source": "src/GraphQLTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/graphql/src/GraphQLTransformer.js b/packages/transformers/graphql/src/GraphQLTransformer.js deleted file mode 100644 index 1c3124474..000000000 --- a/packages/transformers/graphql/src/GraphQLTransformer.js +++ /dev/null @@ -1,30 +0,0 @@ -// @flow -import {Transformer} from '@atlaspack/plugin'; -import {parse, print, Source, stripIgnoredCharacters} from 'graphql'; -import {processDocumentImports} from 'graphql-import-macro'; - -export default (new Transformer({ - async transform({asset, options, resolve}) { - const document = parse(new Source(await asset.getCode(), asset.filePath)); - const expandedDocument = await processDocumentImports(document, loadImport); - - async function loadImport(to, from) { - const filePath = await resolve(to, from); - - asset.invalidateOnFileChange(filePath); - - return parse( - new Source(await options.inputFS.readFile(filePath, 'utf-8'), filePath), - ); - } - - const generated = asset.env.shouldOptimize - ? stripIgnoredCharacters(print(expandedDocument)) - : print(expandedDocument); - - asset.type = 'js'; - asset.setCode(`module.exports=${JSON.stringify(generated)};`); - - return [asset]; - }, -}): Transformer); diff --git a/packages/transformers/graphql/src/GraphQLTransformer.ts b/packages/transformers/graphql/src/GraphQLTransformer.ts new file mode 100644 index 000000000..a05bd06ff --- /dev/null +++ b/packages/transformers/graphql/src/GraphQLTransformer.ts @@ -0,0 +1,29 @@ +import {Transformer} from '@atlaspack/plugin'; +import {parse, print, Source, stripIgnoredCharacters} from 'graphql'; +import {processDocumentImports} from 'graphql-import-macro'; + +export default new Transformer({ + async transform({asset, options, resolve}) { + const document = parse(new Source(await asset.getCode(), asset.filePath)); + const expandedDocument = await processDocumentImports(document, loadImport); + + async function loadImport(to: any, from: any) { + const filePath = await resolve(to, from); + + asset.invalidateOnFileChange(filePath); + + return parse( + new Source(await options.inputFS.readFile(filePath, 'utf-8'), filePath), + ); + } + + const generated = asset.env.shouldOptimize + ? stripIgnoredCharacters(print(expandedDocument)) + : print(expandedDocument); + + asset.type = 'js'; + asset.setCode(`module.exports=${JSON.stringify(generated)};`); + + return [asset]; + }, +}) as Transformer; diff --git a/packages/transformers/html/package.json b/packages/transformers/html/package.json index 86986ab11..7d8544d53 100644 --- a/packages/transformers/html/package.json +++ b/packages/transformers/html/package.json @@ -13,7 +13,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/HTMLTransformer.js", - "source": "src/HTMLTransformer.js", + "types": "src/HTMLTransformer.ts", + "source": "src/HTMLTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/html/src/HTMLTransformer.js b/packages/transformers/html/src/HTMLTransformer.js deleted file mode 100644 index 3374a9773..000000000 --- a/packages/transformers/html/src/HTMLTransformer.js +++ /dev/null @@ -1,134 +0,0 @@ -// @flow - -import {Transformer} from '@atlaspack/plugin'; -import type {AST, Transformer as TransformerOpts} from '@atlaspack/types'; -import {parser as parse} from 'posthtml-parser'; -import nullthrows from 'nullthrows'; -import type {PostHTMLExpression, PostHTMLNode} from 'posthtml'; -import PostHTML from 'posthtml'; -import {render} from 'posthtml-render'; -import semver from 'semver'; -import collectDependencies from './dependencies'; -import extractInlineAssets from './inline'; -import ThrowableDiagnostic from '@atlaspack/diagnostic'; - -export function parseHTML(code: string, xmlMode: boolean): AST { - return { - type: 'posthtml', - version: '0.4.1', - program: parse(code, { - lowerCaseTags: true, - lowerCaseAttributeNames: true, - sourceLocations: true, - xmlMode, - }), - }; -} - -export const transformerOpts: TransformerOpts = { - canReuseAST({ast}) { - return ast.type === 'posthtml' && semver.satisfies(ast.version, '^0.4.0'); - }, - - async parse({asset}) { - const code = await asset.getCode(); - const xmlMode = asset.type === 'xhtml'; - return parseHTML(code, xmlMode); - }, - - async transform({asset, options}) { - if (asset.type === 'htm') { - asset.type = 'html'; - } - - asset.bundleBehavior = 'isolated'; - let ast = nullthrows(await asset.getAST()); - let hasModuleScripts; - try { - hasModuleScripts = collectDependencies(asset, ast); - } catch (errors) { - if (Array.isArray(errors)) { - throw new ThrowableDiagnostic({ - diagnostic: errors.map(error => ({ - message: error.message, - origin: '@atlaspack/transformer-html', - codeFrames: [ - { - filePath: error.filePath, - language: 'html', - codeHighlights: [error.loc], - }, - ], - })), - }); - } - throw errors; - } - - const {assets: inlineAssets, hasModuleScripts: hasInlineModuleScripts} = - extractInlineAssets(asset, ast); - - const result = [asset, ...inlineAssets]; - - // empty is added to make sure HMR is working even if user - // didn't add any. - if (options.hmrOptions && !(hasModuleScripts || hasInlineModuleScripts)) { - const script = { - tag: 'script', - attrs: { - src: asset.addURLDependency('hmr.js', { - priority: 'parallel', - }), - }, - content: [], - }; - - const found = findFirstMatch(ast, [{tag: 'body'}, {tag: 'html'}]); - - if (found) { - found.content = found.content || []; - found.content.push(script); - } else { - // Insert at the very end. - ast.program.push(script); - } - - asset.setAST(ast); - - result.push({ - type: 'js', - content: '', - uniqueKey: 'hmr.js', - }); - } - - return result; - }, - - generate({ast, asset}) { - return { - content: render(ast.program, { - closingSingleTag: asset.type === 'xhtml' ? 'slash' : undefined, - }), - }; - }, -}; -export default (new Transformer(transformerOpts): Transformer); - -function findFirstMatch( - ast: AST, - expressions: PostHTMLExpression[], -): ?PostHTMLNode { - let found; - - for (const expression of expressions) { - PostHTML().match.call(ast.program, expression, node => { - found = node; - return node; - }); - - if (found) { - return found; - } - } -} diff --git a/packages/transformers/html/src/HTMLTransformer.ts b/packages/transformers/html/src/HTMLTransformer.ts new file mode 100644 index 000000000..4c3376d82 --- /dev/null +++ b/packages/transformers/html/src/HTMLTransformer.ts @@ -0,0 +1,136 @@ +import {Transformer} from '@atlaspack/plugin'; +import type {AST, Transformer as TransformerOpts} from '@atlaspack/types'; +import {parser as parse} from 'posthtml-parser'; +import nullthrows from 'nullthrows'; +// @ts-expect-error - TS2305 - Module '"posthtml"' has no exported member 'PostHTMLExpression'. | TS2305 - Module '"posthtml"' has no exported member 'PostHTMLNode'. +import type {PostHTMLExpression, PostHTMLNode} from 'posthtml'; +import PostHTML from 'posthtml'; +import {render} from 'posthtml-render'; +import semver from 'semver'; +import collectDependencies from './dependencies'; +import extractInlineAssets from './inline'; +import ThrowableDiagnostic from '@atlaspack/diagnostic'; + +export function parseHTML(code: string, xmlMode: boolean): AST { + return { + type: 'posthtml', + version: '0.4.1', + program: parse(code, { + lowerCaseTags: true, + lowerCaseAttributeNames: true, + sourceLocations: true, + xmlMode, + }), + }; +} + +export const transformerOpts: TransformerOpts = { + canReuseAST({ast}) { + return ast.type === 'posthtml' && semver.satisfies(ast.version, '^0.4.0'); + }, + + async parse({asset}) { + const code = await asset.getCode(); + const xmlMode = asset.type === 'xhtml'; + return parseHTML(code, xmlMode); + }, + + async transform({asset, options}) { + if (asset.type === 'htm') { + asset.type = 'html'; + } + + asset.bundleBehavior = 'isolated'; + let ast = nullthrows(await asset.getAST()); + let hasModuleScripts; + try { + hasModuleScripts = collectDependencies(asset, ast); + } catch (errors: any) { + if (Array.isArray(errors)) { + throw new ThrowableDiagnostic({ + diagnostic: errors.map((error) => ({ + message: error.message, + origin: '@atlaspack/transformer-html', + codeFrames: [ + { + filePath: error.filePath, + language: 'html', + codeHighlights: [error.loc], + }, + ], + })), + }); + } + throw errors; + } + + const {assets: inlineAssets, hasModuleScripts: hasInlineModuleScripts} = + extractInlineAssets(asset, ast); + + const result = [asset, ...inlineAssets]; + + // empty is added to make sure HMR is working even if user + // didn't add any. + if (options.hmrOptions && !(hasModuleScripts || hasInlineModuleScripts)) { + const script = { + tag: 'script', + attrs: { + src: asset.addURLDependency('hmr.js', { + priority: 'parallel', + }), + }, + content: [], + } as const; + + const found = findFirstMatch(ast, [{tag: 'body'}, {tag: 'html'}]); + + if (found) { + found.content = found.content || []; + found.content.push(script); + } else { + // Insert at the very end. + ast.program.push(script); + } + + asset.setAST(ast); + + result.push({ + type: 'js', + content: '', + uniqueKey: 'hmr.js', + }); + } + + return result; + }, + + generate({ast, asset}) { + return { + content: render(ast.program, { + // @ts-expect-error - TS2322 - Type '"slash" | undefined' is not assignable to type 'closingSingleTagOptionEnum | undefined'. + closingSingleTag: asset.type === 'xhtml' ? 'slash' : undefined, + }), + }; + }, +}; +// @ts-expect-error - TS2345 - Argument of type 'Transformer' is not assignable to parameter of type 'Transformer'. +export default new Transformer(transformerOpts) as Transformer; + +function findFirstMatch( + ast: AST, + expressions: PostHTMLExpression[], +): PostHTMLNode | null | undefined { + let found; + + for (const expression of expressions) { + // @ts-expect-error - TS2339 - Property 'match' does not exist on type 'PostHTML'. | TS7006 - Parameter 'node' implicitly has an 'any' type. + PostHTML().match.call(ast.program, expression, (node) => { + found = node; + return node; + }); + + if (found) { + return found; + } + } +} diff --git a/packages/transformers/html/src/dependencies.js b/packages/transformers/html/src/dependencies.js deleted file mode 100644 index 8e19f16e0..000000000 --- a/packages/transformers/html/src/dependencies.js +++ /dev/null @@ -1,295 +0,0 @@ -// @flow - -import type {AST, MutableAsset, FilePath} from '@atlaspack/types'; -import type {PostHTMLNode} from 'posthtml'; -import PostHTML from 'posthtml'; -import {parse, stringify} from 'srcset'; -// A list of all attributes that may produce a dependency -// Based on https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes -const ATTRS = { - src: [ - 'script', - 'img', - 'audio', - 'video', - 'source', - 'track', - 'iframe', - 'embed', - 'amp-img', - ], - // Using href with - - - `; - const {dependencies, outputCode, transformResult, inputAsset} = - await runTestTransform(code); - assert.equal( - outputCode, - ` - - - - - - `, - ); - assert.deepEqual(normalizeDependencies(dependencies), [ - { - url: 'input.js', - opts: { - bundleBehavior: 'isolated', - env: { - loc: null, - outputFormat: 'global', - sourceType: 'script', - }, - priority: 'parallel', - }, - }, - ]); - - assert.deepEqual(transformResult, [inputAsset]); - }); - - it('transforms simple inline script', async () => { - const code = ` - - - - - - `; - const {transformResult, inputAsset} = await runTestTransform(code); - assert(transformResult.includes(inputAsset)); - const assets = normalizeAssets(transformResult); - assert.deepEqual(assets[1], { - type: 'js', - content: "console.log('blah'); require('path');", - uniqueKey: 'a8a37984d2e520b9', - bundleBehavior: 'inline', - env: null, - meta: null, - }); - }); - - it('we will get one dependency per asset', async () => { - const code = ` - - - - - - - `; - const {dependencies, outputCode, transformResult, inputAsset} = - await runTestTransform(code); - assert.equal( - outputCode, - ` - - - - - - - `, - ); - const opts = { - bundleBehavior: 'isolated', - env: { - loc: null, - outputFormat: 'global', - sourceType: 'script', - }, - priority: 'parallel', - }; - assert.deepEqual(normalizeDependencies(dependencies), [ - { - url: 'input1.js', - opts, - }, - { - url: 'input2.js', - opts, - }, - ]); - - assert.deepEqual(transformResult, [inputAsset]); - }); - - it('transform simple module tag', async () => { - const code = ` - - - - - - `; - const {dependencies, outputCode, transformResult, inputAsset} = - await runTestTransform(code); - assert.equal( - outputCode, - ` - - - - - - `, - ); - assert.deepEqual(normalizeDependencies(dependencies), [ - { - url: 'input.js', - opts: { - bundleBehavior: undefined, - env: { - loc: null, - outputFormat: 'esmodule', - sourceType: 'module', - }, - priority: 'parallel', - }, - }, - ]); - - assert.deepEqual(transformResult, [inputAsset]); - }); - - it('transform simple module tag if there is no support for esmodules', async () => { - const code = ` - - - - - - `; - const {dependencies, outputCode, transformResult, inputAsset} = - await runTestTransform(code, { - shouldScopeHoist: true, - supportsEsmodules: false, - hmrOptions: null, - }); - assert.equal( - normalizeHTML(outputCode), - normalizeHTML(` - - - - - - - `), - ); - assert.deepEqual(normalizeDependencies(dependencies), [ - { - url: 'input.js', - opts: { - bundleBehavior: undefined, - env: { - loc: null, - outputFormat: 'global', - sourceType: 'module', - }, - priority: 'parallel', - }, - }, - { - url: 'input.js', - opts: { - bundleBehavior: undefined, - env: { - loc: null, - outputFormat: 'esmodule', - sourceType: 'module', - }, - priority: 'parallel', - }, - }, - ]); - - assert.deepEqual(transformResult, [inputAsset]); - }); - - it('adds an HMR tag if there are HMR options set', async () => { - const code = ` - - - - - - `; - const {dependencies, outputCode, transformResult, inputAsset} = - await runTestTransform(code, { - shouldScopeHoist: true, - supportsEsmodules: true, - hmrOptions: { - port: 1234, - host: 'localhost', - }, - }); - assert.equal( - normalizeHTML(outputCode), - normalizeHTML(` - - - - - - - `), - ); - assert.deepEqual(normalizeDependencies(dependencies), [ - { - url: 'input.js', - opts: { - bundleBehavior: 'isolated', - env: { - loc: null, - outputFormat: 'global', - sourceType: 'script', - }, - priority: 'parallel', - }, - }, - { - url: 'hmr.js', - opts: { - env: { - loc: null, - }, - priority: 'parallel', - }, - }, - ]); - - assert.deepEqual(transformResult, [ - inputAsset, - { - content: '', - type: 'js', - uniqueKey: 'hmr.js', - }, - ]); - }); -}); diff --git a/packages/transformers/html/test/HTMLTransformer.test.ts b/packages/transformers/html/test/HTMLTransformer.test.ts new file mode 100644 index 000000000..c6a144aab --- /dev/null +++ b/packages/transformers/html/test/HTMLTransformer.test.ts @@ -0,0 +1,360 @@ +// @ts-expect-error - TS2305 - Module '"posthtml-render"' has no exported member 'PostHTMLNode'. +import {PostHTMLNode, render} from 'posthtml-render'; +import {parseHTML, transformerOpts} from '../src/HTMLTransformer'; +import assert from 'assert'; +import type {PluginOptions} from '../../../core/types-internal/src'; + +function normalizeHTML(code: string): string { + const ast = parseHTML(code, true); + const result = renderHTML(ast); + const lines = result + .split('\n') + .map((line: any) => line.trim()) + .filter((line: any) => line); + return lines.join(''); +} + +function renderHTML(newAST: {program: PostHTMLNode}): string { + return render(newAST.program, { + // @ts-expect-error - TS2322 - Type '"slash"' is not assignable to type 'closingSingleTagOptionEnum | undefined'. + closingSingleTag: 'slash', + }); +} + +async function runTestTransform( + code: string, + options: { + shouldScopeHoist: boolean; + supportsEsmodules: boolean; + hmrOptions: unknown; + } = { + shouldScopeHoist: true, + supportsEsmodules: true, + hmrOptions: null, + }, +) { + const dependencies: Array = []; + let newAST = null; + const asset = { + getAST: () => parseHTML(code, true), + setAST: (n: any) => { + newAST = n; + }, + addURLDependency(url: string, opts: Partial) { + dependencies.push({url, opts}); + return `dependency-id::${url}`; + }, + env: { + shouldScopeHoist: options.shouldScopeHoist, + supports(tag: string, defaultValue: boolean) { + assert.equal(tag, 'esmodules'); + assert.equal(defaultValue, true); + return options.supportsEsmodules; + }, + }, + addDependency(specifier: DependencyOptions, specifierType: undefined) { + dependencies.push({specifier, specifierType}); + return 'dependency-id'; + }, + } as const; + + const transformInput = { + asset, + options: { + hmrOptions: options.hmrOptions, + }, + } as const; + // @ts-expect-error - TS2345 - Argument of type '{ readonly asset: { readonly getAST: () => AST; readonly setAST: (n: any) => void; readonly addURLDependency: (url: string, opts: DependencyOptions) => string; readonly env: { readonly shouldScopeHoist: boolean; readonly supports: (tag: string, defaultValue: boolean) => boolean; }; readonly addDependency: (specifier...' is not assignable to parameter of type '{ asset: MutableAsset; config: undefined; resolve: ResolveFn; options: PluginOptions; logger: PluginLogger; tracer: PluginTracer; }'. + const transformResult = await transformerOpts.transform(transformInput); + + // @ts-expect-error - TS2345 - Argument of type 'null' is not assignable to parameter of type '{ program: PostHTMLNode; }'. + const outputCode = renderHTML(newAST); + + return {dependencies, newAST, outputCode, transformResult, inputAsset: asset}; +} + +function normalizeDependencies(dependencies: any) { + return dependencies.map((dependency: any) => ({ + ...dependency, + opts: { + ...dependency.opts, + env: { + // $FlowFixMe + ...dependency.opts.env, + loc: null, + }, + }, + })); +} + +function normalizeAssets(assets: any) { + return assets.map((asset: any) => { + return { + ...asset, + env: null, + meta: null, + }; + }); +} + +describe('HTMLTransformer', () => { + it('transform simple script tag', async () => { + const code = ` + + + + + + `; + const {dependencies, outputCode, transformResult, inputAsset} = + await runTestTransform(code); + assert.equal( + outputCode, + ` + + + + + + `, + ); + assert.deepEqual(normalizeDependencies(dependencies), [ + { + url: 'input.js', + opts: { + bundleBehavior: 'isolated', + env: { + loc: null, + outputFormat: 'global', + sourceType: 'script', + }, + priority: 'parallel', + }, + }, + ]); + + assert.deepEqual(transformResult, [inputAsset]); + }); + + it('transforms simple inline script', async () => { + const code = ` + + + + + + `; + const {transformResult, inputAsset} = await runTestTransform(code); + // @ts-expect-error - TS2345 - Argument of type '{ readonly getAST: () => AST; readonly setAST: (n: any) => void; readonly addURLDependency: (url: string, opts: DependencyOptions) => string; readonly env: { readonly shouldScopeHoist: boolean; readonly supports: (tag: string, defaultValue: boolean) => boolean; }; readonly addDependency: (specifier: DependencyOption...' is not assignable to parameter of type 'MutableAsset | TransformerResult'. + assert(transformResult.includes(inputAsset)); + const assets = normalizeAssets(transformResult); + assert.deepEqual(assets[1], { + type: 'js', + content: "console.log('blah'); require('path');", + uniqueKey: 'a8a37984d2e520b9', + bundleBehavior: 'inline', + env: null, + meta: null, + }); + }); + + it('we will get one dependency per asset', async () => { + const code = ` + + + + + + + `; + const {dependencies, outputCode, transformResult, inputAsset} = + await runTestTransform(code); + assert.equal( + outputCode, + ` + + + + + + + `, + ); + const opts = { + bundleBehavior: 'isolated', + env: { + loc: null, + outputFormat: 'global', + sourceType: 'script', + }, + priority: 'parallel', + } as const; + assert.deepEqual(normalizeDependencies(dependencies), [ + { + url: 'input1.js', + opts, + }, + { + url: 'input2.js', + opts, + }, + ]); + + assert.deepEqual(transformResult, [inputAsset]); + }); + + it('transform simple module tag', async () => { + const code = ` + + + + + + `; + const {dependencies, outputCode, transformResult, inputAsset} = + await runTestTransform(code); + assert.equal( + outputCode, + ` + + + + + + `, + ); + assert.deepEqual(normalizeDependencies(dependencies), [ + { + url: 'input.js', + opts: { + bundleBehavior: undefined, + env: { + loc: null, + outputFormat: 'esmodule', + sourceType: 'module', + }, + priority: 'parallel', + }, + }, + ]); + + assert.deepEqual(transformResult, [inputAsset]); + }); + + it('transform simple module tag if there is no support for esmodules', async () => { + const code = ` + + + + + + `; + const {dependencies, outputCode, transformResult, inputAsset} = + await runTestTransform(code, { + shouldScopeHoist: true, + supportsEsmodules: false, + hmrOptions: null, + }); + assert.equal( + normalizeHTML(outputCode), + normalizeHTML(` + + + + + + + `), + ); + assert.deepEqual(normalizeDependencies(dependencies), [ + { + url: 'input.js', + opts: { + bundleBehavior: undefined, + env: { + loc: null, + outputFormat: 'global', + sourceType: 'module', + }, + priority: 'parallel', + }, + }, + { + url: 'input.js', + opts: { + bundleBehavior: undefined, + env: { + loc: null, + outputFormat: 'esmodule', + sourceType: 'module', + }, + priority: 'parallel', + }, + }, + ]); + + assert.deepEqual(transformResult, [inputAsset]); + }); + + it('adds an HMR tag if there are HMR options set', async () => { + const code = ` + + + + + + `; + const {dependencies, outputCode, transformResult, inputAsset} = + await runTestTransform(code, { + shouldScopeHoist: true, + supportsEsmodules: true, + hmrOptions: { + port: 1234, + host: 'localhost', + }, + }); + assert.equal( + normalizeHTML(outputCode), + normalizeHTML(` + + + + + + + `), + ); + assert.deepEqual(normalizeDependencies(dependencies), [ + { + url: 'input.js', + opts: { + bundleBehavior: 'isolated', + env: { + loc: null, + outputFormat: 'global', + sourceType: 'script', + }, + priority: 'parallel', + }, + }, + { + url: 'hmr.js', + opts: { + env: { + loc: null, + }, + priority: 'parallel', + }, + }, + ]); + + assert.deepEqual(transformResult, [ + inputAsset, + { + content: '', + type: 'js', + uniqueKey: 'hmr.js', + }, + ]); + }); +}); diff --git a/packages/transformers/image/package.json b/packages/transformers/image/package.json index 224529349..85b16e864 100644 --- a/packages/transformers/image/package.json +++ b/packages/transformers/image/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/ImageTransformer.js", - "source": "src/ImageTransformer.js", + "types": "src/ImageTransformer.ts", + "source": "src/ImageTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/image/src/ImageTransformer.js b/packages/transformers/image/src/ImageTransformer.js deleted file mode 100644 index 308cc0b20..000000000 --- a/packages/transformers/image/src/ImageTransformer.js +++ /dev/null @@ -1,120 +0,0 @@ -// @flow -import {validateConfig} from './validateConfig'; -import {Transformer} from '@atlaspack/plugin'; -import nullthrows from 'nullthrows'; -import WorkerFarm from '@atlaspack/workers'; -import loadSharp from './loadSharp'; - -// from https://github.com/lovell/sharp/blob/df7b8ba73808fc494be413e88cfb621b6279218c/lib/output.js#L6-L17 -const FORMATS = new Map([ - ['jpeg', 'jpeg'], - ['jpg', 'jpeg'], - ['png', 'png'], - ['webp', 'webp'], - ['gif', 'gif'], - ['tiff', 'tiff'], - ['avif', 'avif'], - ['heic', 'heif'], - ['heif', 'heif'], -]); - -let isSharpLoadedOnMainThread = false; - -export default (new Transformer({ - async loadConfig({config}) { - let configFile: any = await config.getConfig( - ['sharp.config.json'], // '.sharprc', '.sharprc.json' - {packageKey: 'sharp'}, - ); - - if (configFile?.contents) { - validateConfig(configFile.contents, configFile.filePath); - return configFile.contents; - } else { - return {}; - } - }, - - async transform({config, asset, options}) { - asset.bundleBehavior = 'isolated'; - - const originalFormat = FORMATS.get(asset.type); - if (!originalFormat) { - throw new Error( - `The image transformer does not support ${asset.type} images.`, - ); - } - - const width = asset.query.has('width') - ? parseInt(asset.query.get('width'), 10) - : null; - const height = asset.query.has('height') - ? parseInt(asset.query.get('height'), 10) - : null; - const quality = asset.query.has('quality') - ? parseInt(asset.query.get('quality'), 10) - : config.quality; - let targetFormat = asset.query.get('as')?.toLowerCase().trim(); - if (targetFormat && !FORMATS.has(targetFormat)) { - throw new Error( - `The image transformer does not support ${targetFormat} images.`, - ); - } - - const format = nullthrows(FORMATS.get(targetFormat || originalFormat)); - const outputOptions = config[format]; - - if (width || height || quality || targetFormat || outputOptions) { - // Sharp must be required from the main thread as well to prevent errors when workers exit - // See https://sharp.pixelplumbing.com/install#worker-threads and https://github.com/lovell/sharp/issues/2263 - if (WorkerFarm.isWorker() && !isSharpLoadedOnMainThread) { - let api = WorkerFarm.getWorkerApi(); - await api.callMaster({ - location: __dirname + '/loadSharp.js', - args: [ - options.packageManager, - asset.filePath, - options.shouldAutoInstall, - ], - }); - - isSharpLoadedOnMainThread = true; - } - - let inputBuffer = await asset.getBuffer(); - let sharp = await loadSharp( - options.packageManager, - asset.filePath, - options.shouldAutoInstall, - true, - ); - - let imagePipeline = sharp(inputBuffer, {animated: true}); - - imagePipeline.withMetadata(); - - if (width || height) { - imagePipeline.resize(width, height); - } - - imagePipeline.rotate(); - - const normalizedOutputOptions = outputOptions || {}; - if (format === 'jpeg') { - normalizedOutputOptions.mozjpeg = - normalizedOutputOptions.mozjpeg ?? true; - } - imagePipeline[format]({ - quality, - ...normalizedOutputOptions, - }); - - asset.type = format; - - let buffer = await imagePipeline.toBuffer(); - asset.setBuffer(buffer); - } - - return [asset]; - }, -}): Transformer); diff --git a/packages/transformers/image/src/ImageTransformer.ts b/packages/transformers/image/src/ImageTransformer.ts new file mode 100644 index 000000000..2ad34a677 --- /dev/null +++ b/packages/transformers/image/src/ImageTransformer.ts @@ -0,0 +1,127 @@ +import {validateConfig} from './validateConfig'; +import {Transformer} from '@atlaspack/plugin'; +import nullthrows from 'nullthrows'; +import WorkerFarm from '@atlaspack/workers'; +// @ts-expect-error - TS1192 - Module '"/home/ubuntu/parcel/packages/transformers/image/src/loadSharp"' has no default export. +import loadSharp from './loadSharp'; + +// from https://github.com/lovell/sharp/blob/df7b8ba73808fc494be413e88cfb621b6279218c/lib/output.js#L6-L17 +const FORMATS = new Map([ + ['jpeg', 'jpeg'], + ['jpg', 'jpeg'], + ['png', 'png'], + ['webp', 'webp'], + ['gif', 'gif'], + ['tiff', 'tiff'], + ['avif', 'avif'], + ['heic', 'heif'], + ['heif', 'heif'], +]); + +let isSharpLoadedOnMainThread = false; + +export default new Transformer({ + async loadConfig({config}) { + let configFile: any = await config.getConfig( + ['sharp.config.json'], // '.sharprc', '.sharprc.json' + {packageKey: 'sharp'}, + ); + + if (configFile?.contents) { + validateConfig(configFile.contents, configFile.filePath); + return configFile.contents; + } else { + return {}; + } + }, + + async transform({config, asset, options}) { + asset.bundleBehavior = 'isolated'; + + const originalFormat = FORMATS.get(asset.type); + if (!originalFormat) { + throw new Error( + `The image transformer does not support ${asset.type} images.`, + ); + } + + const width = asset.query.has('width') + ? // @ts-expect-error - TS2345 - Argument of type 'string | null' is not assignable to parameter of type 'string'. + parseInt(asset.query.get('width'), 10) + : null; + const height = asset.query.has('height') + ? // @ts-expect-error - TS2345 - Argument of type 'string | null' is not assignable to parameter of type 'string'. + parseInt(asset.query.get('height'), 10) + : null; + const quality = asset.query.has('quality') + ? // @ts-expect-error - TS2345 - Argument of type 'string | null' is not assignable to parameter of type 'string'. + parseInt(asset.query.get('quality'), 10) + : // @ts-expect-error - TS2571 - Object is of type 'unknown'. + config.quality; + let targetFormat = asset.query.get('as')?.toLowerCase().trim(); + if (targetFormat && !FORMATS.has(targetFormat)) { + throw new Error( + `The image transformer does not support ${targetFormat} images.`, + ); + } + + const format = nullthrows(FORMATS.get(targetFormat || originalFormat)); + // @ts-expect-error - TS2571 - Object is of type 'unknown'. + const outputOptions = config[format]; + + if (width || height || quality || targetFormat || outputOptions) { + // Sharp must be required from the main thread as well to prevent errors when workers exit + // See https://sharp.pixelplumbing.com/install#worker-threads and https://github.com/lovell/sharp/issues/2263 + // @ts-expect-error - TS2339 - Property 'isWorker' does not exist on type 'typeof WorkerFarm'. + if (WorkerFarm.isWorker() && !isSharpLoadedOnMainThread) { + // @ts-expect-error - TS2339 - Property 'getWorkerApi' does not exist on type 'typeof WorkerFarm'. + let api = WorkerFarm.getWorkerApi(); + await api.callMaster({ + location: __dirname + '/loadSharp.js', + args: [ + options.packageManager, + asset.filePath, + options.shouldAutoInstall, + ], + }); + + isSharpLoadedOnMainThread = true; + } + + let inputBuffer = await asset.getBuffer(); + let sharp = await loadSharp( + options.packageManager, + asset.filePath, + options.shouldAutoInstall, + true, + ); + + let imagePipeline = sharp(inputBuffer, {animated: true}); + + imagePipeline.withMetadata(); + + if (width || height) { + imagePipeline.resize(width, height); + } + + imagePipeline.rotate(); + + const normalizedOutputOptions = outputOptions || {}; + if (format === 'jpeg') { + normalizedOutputOptions.mozjpeg = + normalizedOutputOptions.mozjpeg ?? true; + } + imagePipeline[format]({ + quality, + ...normalizedOutputOptions, + }); + + asset.type = format; + + let buffer = await imagePipeline.toBuffer(); + asset.setBuffer(buffer); + } + + return [asset]; + }, +}) as Transformer; diff --git a/packages/transformers/image/src/loadSharp.js b/packages/transformers/image/src/loadSharp.js deleted file mode 100644 index 3f01cc164..000000000 --- a/packages/transformers/image/src/loadSharp.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow -import type {PackageManager} from '@atlaspack/package-manager'; -import type {FilePath} from '@atlaspack/types'; - -const SHARP_RANGE = '^0.31.1'; - -// This is used to load sharp on the main thread, which prevents errors when worker threads exit -// See https://sharp.pixelplumbing.com/install#worker-threads and https://github.com/lovell/sharp/issues/2263 -module.exports = async ( - packageManager: PackageManager, - filePath: FilePath, - shouldAutoInstall: boolean, - shouldReturn: boolean, -): Promise => { - let sharp = await packageManager.require('sharp', filePath, { - range: SHARP_RANGE, - shouldAutoInstall: shouldAutoInstall, - }); - - if (shouldReturn) { - return sharp; - } -}; diff --git a/packages/transformers/image/src/loadSharp.ts b/packages/transformers/image/src/loadSharp.ts new file mode 100644 index 000000000..7662036b8 --- /dev/null +++ b/packages/transformers/image/src/loadSharp.ts @@ -0,0 +1,22 @@ +import type {PackageManager} from '@atlaspack/package-manager'; +import type {FilePath} from '@atlaspack/types'; + +const SHARP_RANGE = '^0.31.1'; + +// This is used to load sharp on the main thread, which prevents errors when worker threads exit +// See https://sharp.pixelplumbing.com/install#worker-threads and https://github.com/lovell/sharp/issues/2263 +module.exports = async ( + packageManager: PackageManager, + filePath: FilePath, + shouldAutoInstall: boolean, + shouldReturn: boolean, +): Promise => { + let sharp = await packageManager.require('sharp', filePath, { + range: SHARP_RANGE, + shouldAutoInstall: shouldAutoInstall, + }); + + if (shouldReturn) { + return sharp; + } +}; diff --git a/packages/transformers/image/src/validateConfig.js b/packages/transformers/image/src/validateConfig.js deleted file mode 100644 index 730d82ad2..000000000 --- a/packages/transformers/image/src/validateConfig.js +++ /dev/null @@ -1,260 +0,0 @@ -// @flow -import type {SchemaEntity} from '@atlaspack/utils'; -import {validateSchema} from '@atlaspack/utils'; - -// https://sharp.pixelplumbing.com/api-output#jpeg -const JPEG_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - progressive: { - type: 'boolean', - }, - chromaSubsampling: { - type: 'string', - }, - optimiseCoding: { - type: 'boolean', - }, - optimizeCoding: { - type: 'boolean', - }, - mozjpeg: { - type: 'boolean', - }, - trellisQuantisation: { - type: 'boolean', - }, - overshootDeringing: { - type: 'boolean', - }, - optimiseScans: { - type: 'boolean', - }, - optimizeScans: { - type: 'boolean', - }, - quantisationTable: { - type: 'number', - }, - quantizationTable: { - type: 'number', - }, - force: { - type: 'boolean', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#png -const PNG_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - progressive: { - type: 'boolean', - }, - compressionLevel: { - type: 'number', - }, - adaptiveFiltering: { - type: 'boolean', - }, - palette: { - type: 'boolean', - }, - colours: { - type: 'number', - }, - colors: { - type: 'number', - }, - dither: { - type: 'number', - }, - force: { - type: 'boolean', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#webp -const WEBP_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - alphaQuality: { - type: 'number', - }, - lossless: { - type: 'boolean', - }, - nearLossless: { - type: 'boolean', - }, - smartSubsample: { - type: 'boolean', - }, - reductionEffort: { - type: 'number', - }, - pageHeight: { - type: 'number', - }, - loop: { - type: 'number', - }, - delay: { - type: 'array', - items: { - type: 'number', - }, - }, - force: { - type: 'boolean', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#gif -const GIF_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - pageHeight: { - type: 'number', - }, - loop: { - type: 'number', - }, - delay: { - type: 'array', - items: { - type: 'number', - }, - }, - force: { - type: 'boolean', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#tiff -const TIFF_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - force: { - type: 'boolean', - }, - compression: { - type: 'string', - }, - predictor: { - type: 'string', - }, - pyramid: { - type: 'boolean', - }, - tile: { - type: 'boolean', - }, - tileWidth: { - type: 'number', - }, - tileHeight: { - type: 'number', - }, - xres: { - type: 'number', - }, - yres: { - type: 'number', - }, - bitdepth: { - type: 'number', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#avif -const AVIF_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - lossless: { - type: 'boolean', - }, - speed: { - type: 'number', - }, - chromaSubsampling: { - type: 'string', - }, - }, - additionalProperties: true, -}; - -// https://sharp.pixelplumbing.com/api-output#heif -const HEIF_OUTPUT_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - quality: { - type: 'number', - }, - compression: { - type: 'string', - }, - lossless: { - type: 'boolean', - }, - speed: { - type: 'number', - }, - chromaSubsampling: { - type: 'string', - }, - }, - additionalProperties: true, -}; - -const CONFIG_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - // Fallback quality - quality: { - type: 'number', - }, - jpeg: JPEG_OUTPUT_SCHEMA, - png: PNG_OUTPUT_SCHEMA, - webp: WEBP_OUTPUT_SCHEMA, - gif: GIF_OUTPUT_SCHEMA, - tiff: TIFF_OUTPUT_SCHEMA, - avif: AVIF_OUTPUT_SCHEMA, - heif: HEIF_OUTPUT_SCHEMA, - }, - additionalProperties: false, -}; - -export function validateConfig(data: any, filePath: string) { - validateSchema.diagnostic( - CONFIG_SCHEMA, - {data, filePath}, - '@atlaspack/transformer-image', - 'Invalid sharp config', - ); -} diff --git a/packages/transformers/image/src/validateConfig.ts b/packages/transformers/image/src/validateConfig.ts new file mode 100644 index 000000000..1c0df1137 --- /dev/null +++ b/packages/transformers/image/src/validateConfig.ts @@ -0,0 +1,259 @@ +import type {SchemaEntity} from '@atlaspack/utils'; +import {validateSchema} from '@atlaspack/utils'; + +// https://sharp.pixelplumbing.com/api-output#jpeg +const JPEG_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + progressive: { + type: 'boolean', + }, + chromaSubsampling: { + type: 'string', + }, + optimiseCoding: { + type: 'boolean', + }, + optimizeCoding: { + type: 'boolean', + }, + mozjpeg: { + type: 'boolean', + }, + trellisQuantisation: { + type: 'boolean', + }, + overshootDeringing: { + type: 'boolean', + }, + optimiseScans: { + type: 'boolean', + }, + optimizeScans: { + type: 'boolean', + }, + quantisationTable: { + type: 'number', + }, + quantizationTable: { + type: 'number', + }, + force: { + type: 'boolean', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#png +const PNG_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + progressive: { + type: 'boolean', + }, + compressionLevel: { + type: 'number', + }, + adaptiveFiltering: { + type: 'boolean', + }, + palette: { + type: 'boolean', + }, + colours: { + type: 'number', + }, + colors: { + type: 'number', + }, + dither: { + type: 'number', + }, + force: { + type: 'boolean', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#webp +const WEBP_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + alphaQuality: { + type: 'number', + }, + lossless: { + type: 'boolean', + }, + nearLossless: { + type: 'boolean', + }, + smartSubsample: { + type: 'boolean', + }, + reductionEffort: { + type: 'number', + }, + pageHeight: { + type: 'number', + }, + loop: { + type: 'number', + }, + delay: { + type: 'array', + items: { + type: 'number', + }, + }, + force: { + type: 'boolean', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#gif +const GIF_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + pageHeight: { + type: 'number', + }, + loop: { + type: 'number', + }, + delay: { + type: 'array', + items: { + type: 'number', + }, + }, + force: { + type: 'boolean', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#tiff +const TIFF_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + force: { + type: 'boolean', + }, + compression: { + type: 'string', + }, + predictor: { + type: 'string', + }, + pyramid: { + type: 'boolean', + }, + tile: { + type: 'boolean', + }, + tileWidth: { + type: 'number', + }, + tileHeight: { + type: 'number', + }, + xres: { + type: 'number', + }, + yres: { + type: 'number', + }, + bitdepth: { + type: 'number', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#avif +const AVIF_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + lossless: { + type: 'boolean', + }, + speed: { + type: 'number', + }, + chromaSubsampling: { + type: 'string', + }, + }, + additionalProperties: true, +}; + +// https://sharp.pixelplumbing.com/api-output#heif +const HEIF_OUTPUT_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + quality: { + type: 'number', + }, + compression: { + type: 'string', + }, + lossless: { + type: 'boolean', + }, + speed: { + type: 'number', + }, + chromaSubsampling: { + type: 'string', + }, + }, + additionalProperties: true, +}; + +const CONFIG_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + // Fallback quality + quality: { + type: 'number', + }, + jpeg: JPEG_OUTPUT_SCHEMA, + png: PNG_OUTPUT_SCHEMA, + webp: WEBP_OUTPUT_SCHEMA, + gif: GIF_OUTPUT_SCHEMA, + tiff: TIFF_OUTPUT_SCHEMA, + avif: AVIF_OUTPUT_SCHEMA, + heif: HEIF_OUTPUT_SCHEMA, + }, + additionalProperties: false, +}; + +export function validateConfig(data: any, filePath: string) { + validateSchema.diagnostic( + CONFIG_SCHEMA, + {data, filePath}, + '@atlaspack/transformer-image', + 'Invalid sharp config', + ); +} diff --git a/packages/transformers/inline-string/package.json b/packages/transformers/inline-string/package.json index d89bb52f9..18b12ded6 100644 --- a/packages/transformers/inline-string/package.json +++ b/packages/transformers/inline-string/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/InlineStringTransformer.js", - "source": "src/InlineStringTransformer.js", + "types": "src/InlineStringTransformer.ts", + "source": "src/InlineStringTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/inline-string/src/InlineStringTransformer.js b/packages/transformers/inline-string/src/InlineStringTransformer.js deleted file mode 100644 index af507c6ea..000000000 --- a/packages/transformers/inline-string/src/InlineStringTransformer.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow strict-local - -import {Transformer} from '@atlaspack/plugin'; - -export default (new Transformer({ - transform({asset}) { - asset.bundleBehavior = 'inline'; - asset.meta.inlineType = 'string'; - return [asset]; - }, -}): Transformer); diff --git a/packages/transformers/inline-string/src/InlineStringTransformer.ts b/packages/transformers/inline-string/src/InlineStringTransformer.ts new file mode 100644 index 000000000..4fab6b81b --- /dev/null +++ b/packages/transformers/inline-string/src/InlineStringTransformer.ts @@ -0,0 +1,9 @@ +import {Transformer} from '@atlaspack/plugin'; + +export default new Transformer({ + transform({asset}) { + asset.bundleBehavior = 'inline'; + asset.meta.inlineType = 'string'; + return [asset]; + }, +}) as Transformer; diff --git a/packages/transformers/inline/package.json b/packages/transformers/inline/package.json index 6796d5f75..8944ba77c 100644 --- a/packages/transformers/inline/package.json +++ b/packages/transformers/inline/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/InlineTransformer.js", - "source": "src/InlineTransformer.js", + "types": "src/InlineTransformer.ts", + "source": "src/InlineTransformer.ts", "engines": { "node": ">= 16.0.0", "parcel": "^2.12.0" diff --git a/packages/transformers/inline/src/InlineTransformer.js b/packages/transformers/inline/src/InlineTransformer.js deleted file mode 100644 index 593f66215..000000000 --- a/packages/transformers/inline/src/InlineTransformer.js +++ /dev/null @@ -1,10 +0,0 @@ -// @flow strict-local - -import {Transformer} from '@atlaspack/plugin'; - -export default (new Transformer({ - transform({asset}) { - asset.bundleBehavior = 'inline'; - return [asset]; - }, -}): Transformer); diff --git a/packages/transformers/inline/src/InlineTransformer.ts b/packages/transformers/inline/src/InlineTransformer.ts new file mode 100644 index 000000000..38441ae04 --- /dev/null +++ b/packages/transformers/inline/src/InlineTransformer.ts @@ -0,0 +1,8 @@ +import {Transformer} from '@atlaspack/plugin'; + +export default new Transformer({ + transform({asset}) { + asset.bundleBehavior = 'inline'; + return [asset]; + }, +}) as Transformer; diff --git a/packages/transformers/js/package.json b/packages/transformers/js/package.json index 1a38356e3..7c3664134 100644 --- a/packages/transformers/js/package.json +++ b/packages/transformers/js/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/atlassian-labs/atlaspack.git" }, "main": "lib/JSTransformer.js", - "source": "src/JSTransformer.js", + "types": "src/JSTransformer.ts", + "source": "src/JSTransformer.ts", "scripts": { "test": "mocha" }, diff --git a/packages/transformers/js/src/JSTransformer.js b/packages/transformers/js/src/JSTransformer.js deleted file mode 100644 index a182cf8a5..000000000 --- a/packages/transformers/js/src/JSTransformer.js +++ /dev/null @@ -1,1072 +0,0 @@ -// @flow -import type { - JSONObject, - EnvMap, - SourceLocation, - FilePath, - FileCreateInvalidation, -} from '@atlaspack/types'; -import type {SchemaEntity} from '@atlaspack/utils'; -import type {Diagnostic} from '@atlaspack/diagnostic'; -import SourceMap from '@parcel/source-map'; -import {Transformer} from '@atlaspack/plugin'; -import {transform, transformAsync} from '@atlaspack/rust'; -import browserslist from 'browserslist'; -import semver from 'semver'; -import nullthrows from 'nullthrows'; -import ThrowableDiagnostic, { - encodeJSONKeyComponent, - convertSourceLocationToHighlight, -} from '@atlaspack/diagnostic'; -import {validateSchema, remapSourceLocation, globMatch} from '@atlaspack/utils'; -import pkg from '../package.json'; - -const JSX_EXTENSIONS = { - jsx: true, - tsx: true, -}; - -const JSX_PRAGMA = { - react: { - pragma: 'React.createElement', - pragmaFrag: 'React.Fragment', - automatic: '>= 17.0.0 || ^16.14.0 || >= 0.0.0-0 < 0.0.0', - }, - preact: { - pragma: 'h', - pragmaFrag: 'Fragment', - automatic: '>= 10.5.0', - }, - nervjs: { - pragma: 'Nerv.createElement', - pragmaFrag: undefined, - automatic: undefined, - }, - hyperapp: { - pragma: 'h', - pragmaFrag: undefined, - automatic: undefined, - }, -}; - -const BROWSER_MAPPING = { - and_chr: 'chrome', - and_ff: 'firefox', - ie_mob: 'ie', - ios_saf: 'ios', - op_mob: 'opera', - and_qq: null, - and_uc: null, - baidu: null, - bb: null, - kaios: null, - op_mini: null, -}; - -// List of browsers to exclude when the esmodule target is specified. -// Based on https://caniuse.com/#feat=es6-module -const ESMODULE_BROWSERS = [ - 'not ie <= 11', - 'not edge < 16', - 'not firefox < 60', - 'not chrome < 61', - 'not safari < 11', - 'not opera < 48', - 'not ios_saf < 11', - 'not op_mini all', - 'not android < 76', - 'not blackberry > 0', - 'not op_mob > 0', - 'not and_chr < 76', - 'not and_ff < 68', - 'not ie_mob > 0', - 'not and_uc > 0', - 'not samsung < 8.2', - 'not and_qq > 0', - 'not baidu > 0', - 'not kaios > 0', -]; - -const CONFIG_SCHEMA: SchemaEntity = { - type: 'object', - properties: { - inlineFS: { - type: 'boolean', - }, - inlineEnvironment: { - oneOf: [ - { - type: 'boolean', - }, - { - type: 'array', - items: { - type: 'string', - }, - }, - ], - }, - unstable_inlineConstants: { - type: 'boolean', - }, - }, - additionalProperties: false, -}; - -const SCRIPT_ERRORS = { - browser: { - message: 'Browser scripts cannot have imports or exports.', - hint: 'Add the type="module" attribute to the