diff --git a/README.md b/README.md index 2c833ec..c18cf5e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ This is a [lerna](https://github.com/lerna/lerna) powered mono-repo, composed of - [flow-runtime](./packages/flow-runtime): The core runtime type system. - [babel-plugin-flow-runtime](./packages/babel-plugin-flow-runtime): A babel plugin which transforms Flow type annotations into `flow-runtime` invocations. + - [flow-runtime-loader](./packages/flow-runtime-loader): A webpack loader which transforms Flow type files into `flow-runtime` modules. - [flow-runtime-validators](./packages/flow-runtime-validators): A collection of common validators for use with flow-runtime. - [flow-config-parser](./packages/flow-config-parser): Parses flow configuration files. - [flow-runtime-mobx](./packages/flow-runtime-mobx): Adds mobx support to flow-runtime. diff --git a/packages/flow-runtime-loader/.babelrc b/packages/flow-runtime-loader/.babelrc new file mode 100644 index 0000000..e4a4469 --- /dev/null +++ b/packages/flow-runtime-loader/.babelrc @@ -0,0 +1,16 @@ +{ + "presets": [ + ["@babel/preset-env", { + "targets": { + "node": 4 + }, + "exclude": [ + "transform-regenerator" + ] + }], + "@babel/preset-flow" + ], + "plugins": [ + "@babel/plugin-proposal-object-rest-spread" + ] +} diff --git a/packages/flow-runtime-loader/README.md b/packages/flow-runtime-loader/README.md new file mode 100644 index 0000000..fbf6af1 --- /dev/null +++ b/packages/flow-runtime-loader/README.md @@ -0,0 +1,60 @@ +# flow-runtime-loader + +A webpack loader for importing Flow type declarations as `flow-runtime` runtime validators. + +This is useful when a package ships Flow definition files next to its source, for example `*.js.flow` files, and you want to validate values against those definitions at runtime. + +## Installation + +```sh +npm install --save-dev flow-runtime-loader +``` + +## Inline Usage + +```js +import { RawDraftContentState } from 'flow-runtime-loader!draft-js/lib/RawDraftContentState.js.flow'; + +RawDraftContentState.assert(value); +``` + +## Webpack Rule + +```js +module.exports = { + resolve: { + extensions: ['.js.flow', '.js', '.json'] + }, + module: { + rules: [ + { + test: /\.js\.flow$/, + use: [ + { + loader: 'flow-runtime-loader', + options: { + annotate: false + } + } + ] + } + ] + } +}; +``` + +Loader options are passed to `babel-plugin-flow-runtime`. Use the `babel` option for Babel transform options: + +```js +{ + loader: 'flow-runtime-loader', + options: { + libraryName: './custom-flow-runtime', + babel: { + sourceMaps: true + } + } +} +``` + +By default the loader disables project-level Babel config so that importing a dependency's `*.js.flow` file does not accidentally inherit the consuming app's transforms. Pass `babel.babelrc` or `babel.configFile` to opt back in. diff --git a/packages/flow-runtime-loader/flow-runtime-loader.js b/packages/flow-runtime-loader/flow-runtime-loader.js new file mode 100644 index 0000000..c24cc26 --- /dev/null +++ b/packages/flow-runtime-loader/flow-runtime-loader.js @@ -0,0 +1,3 @@ +var loader = require('./lib'); + +module.exports = loader.default || loader; diff --git a/packages/flow-runtime-loader/package.json b/packages/flow-runtime-loader/package.json new file mode 100644 index 0000000..c53c075 --- /dev/null +++ b/packages/flow-runtime-loader/package.json @@ -0,0 +1,43 @@ +{ + "name": "flow-runtime-loader", + "homepage": "https://codemix.github.io/flow-runtime", + "repository": "https://github.com/codemix/flow-runtime.git", + "version": "0.20.0", + "description": "Webpack loader for importing Flow type declarations as flow-runtime modules.", + "main": "flow-runtime-loader.js", + "scripts": { + "lint": "eslint ./src", + "lint-fix": "eslint --fix ./src", + "prepublishOnly": "npm run lint && npm run test && npm run build", + "build": "rimraf ./lib && babel -d ./lib ./src", + "test": "mocha --timeout 10000", + "watch": "mocha --timeout 10000 --watch" + }, + "author": "", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.0", + "@babel/preset-flow": "^7.12.0", + "babel-plugin-flow-runtime": "^0.20.0" + }, + "devDependencies": { + "@babel/cli": "^7.12.0", + "@babel/plugin-proposal-object-rest-spread": "^7.12.0", + "@babel/polyfill": "^7.12.0", + "@babel/preset-env": "^7.12.0", + "@babel/register": "^7.12.0", + "babel-loader": "^8.1.0", + "eslint": "^5.0.0", + "eslint-plugin-babel": "^5.0.0", + "eslint-plugin-flowtype": "^3.0.0", + "eslint-plugin-import": "^2.0.1", + "eslint-plugin-jsx-a11y": "^6.0.0", + "eslint-plugin-react": "^7.0.0", + "mocha": "^5.0.0", + "rimraf": "^2.6.3", + "webpack": "^4.43.0" + }, + "eslintConfig": { + "extends": "../babel-plugin-flow-runtime/config/eslint.js" + } +} diff --git a/packages/flow-runtime-loader/src/index.js b/packages/flow-runtime-loader/src/index.js new file mode 100644 index 0000000..aecf7db --- /dev/null +++ b/packages/flow-runtime-loader/src/index.js @@ -0,0 +1,129 @@ +/* @flow */ + +import {transformSync} from '@babel/core'; +import flowRuntimePlugin from 'babel-plugin-flow-runtime'; + +type LoaderContext = { + async?: () => (error: ?Error, result?: string, sourceMap?: Object) => void; + cacheable?: () => void; + getOptions?: () => Object; + query?: string | Object; + resourcePath?: string; + sourceMap?: boolean; +}; + +type LoaderOptions = { + babel?: Object; +}; + +export default function flowRuntimeLoader (source: string | Buffer, inputSourceMap?: Object) { + if (this.cacheable) { + this.cacheable(); + } + + const callback = this.async && this.async(); + const input = Buffer.isBuffer(source) ? source.toString() : source; + + try { + const options = getOptions(this); + const babelOptions = options.babel || {}; + const flowRuntimeOptions = getFlowRuntimeOptions(options); + const result = transformSync(input, { + ...babelOptions, + babelrc: babelOptions.babelrc === undefined ? false : babelOptions.babelrc, + configFile: babelOptions.configFile === undefined ? false : babelOptions.configFile, + filename: babelOptions.filename || this.resourcePath, + inputSourceMap, + plugins: getPlugins(babelOptions.plugins, flowRuntimeOptions), + presets: getPresets(babelOptions.presets), + sourceMaps: babelOptions.sourceMaps === undefined ? this.sourceMap || Boolean(inputSourceMap) : babelOptions.sourceMaps + }); + + const code = result && result.code ? result.code : input; + const map = result && result.map ? result.map : inputSourceMap; + + if (callback) { + callback(null, code, map); + return; + } + return code; + } + catch (error) { + if (callback) { + callback(error); + return; + } + throw error; + } +} + +function getOptions (context: LoaderContext): LoaderOptions { + if (typeof context.getOptions === 'function') { + return context.getOptions() || {}; + } + + const {query} = context; + if (!query) { + return {}; + } + if (typeof query === 'object') { + return query; + } + return parseQuery(query); +} + +function getFlowRuntimeOptions (options: LoaderOptions): Object { + const pluginOptions = {...options}; + delete pluginOptions.babel; + return pluginOptions; +} + +function getPlugins (plugins?: Array, flowRuntimeOptions: Object): Array { + const result = plugins ? plugins.slice() : []; + result.push([flowRuntimePlugin, flowRuntimeOptions]); + return result; +} + +function getPresets (presets?: Array): Array { + const result = presets ? presets.slice() : []; + result.push(require.resolve('@babel/preset-flow')); + return result; +} + +function parseQuery (query: string): Object { + const raw = query.charAt(0) === '?' ? query.slice(1) : query; + if (!raw) { + return {}; + } + if (raw.charAt(0) === '{') { + return JSON.parse(raw); + } + + return raw.split('&').reduce((options, segment) => { + if (!segment) { + return options; + } + + const pair = segment.split('='); + const key = decodeURIComponent(pair[0]); + const value = pair.length > 1 ? decodeURIComponent(pair.slice(1).join('=')) : undefined; + options[key] = coerceQueryValue(value); + return options; + }, {}); +} + +function coerceQueryValue (value?: string): any { + if (value === undefined || value === '') { + return true; + } + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + if (value === 'null') { + return null; + } + return value; +} diff --git a/packages/flow-runtime-loader/src/index.test.js b/packages/flow-runtime-loader/src/index.test.js new file mode 100644 index 0000000..8ce96bc --- /dev/null +++ b/packages/flow-runtime-loader/src/index.test.js @@ -0,0 +1,166 @@ +/* @flow */ + +import path from 'path'; +import webpack from 'webpack'; +import {equal, ok} from 'assert'; + +import flowRuntimeLoader from './index'; + +function runLoader (source: string | Buffer, options: Object = {}, context: Object = {}): Promise { + let cacheable = false; + + return new Promise((resolve, reject) => { + const loaderContext = { + resourcePath: '/project/types.js.flow', + sourceMap: false, + getOptions: () => options, + cacheable: () => { + cacheable = true; + }, + async: () => (error, code, sourceMap) => { + if (error) { + reject(error); + } + else { + resolve({cacheable, code, sourceMap}); + } + }, + ...context + }; + + flowRuntimeLoader.call(loaderContext, source); + }); +} + +function compileFixture (): Promise { + return new Promise((resolve, reject) => { + webpack({ + mode: 'development', + target: 'node', + entry: path.join(__dirname, '../test/fixtures/webpack/entry.js'), + output: { + filename: 'bundle.js', + libraryTarget: 'commonjs2', + path: path.join(__dirname, '../test/fixtures/webpack/dist') + }, + resolveLoader: { + alias: { + 'flow-runtime-loader': path.join(__dirname, '../src/index.js') + } + }, + resolve: { + alias: { + 'flow-runtime': path.join(__dirname, '../test/fixtures/webpack/flow-runtime.js') + } + }, + module: { + rules: [ + { + test: /\.js\.flow$/, + use: [ + { + loader: 'flow-runtime-loader', + options: { + annotate: false + } + } + ] + } + ] + } + }, (error, stats) => { + if (error) { + reject(error); + return; + } + if (stats.hasErrors()) { + reject(new Error(stats.toString())); + return; + } + resolve(stats); + }); + }); +} + +describe('flow-runtime-loader', () => { + it('converts exported Flow types into runtime declarations', async () => { + const result = await runLoader(` + // @flow + export type User = { + id: number, + name: string + }; + `); + + ok(result.cacheable); + ok(result.code.includes('import t from "flow-runtime";')); + ok(result.code.includes('export const User = t.type("User"')); + ok(result.code.includes('t.property("id", t.number())')); + ok(!result.code.includes('export type User')); + }); + + it('passes loader options to babel-plugin-flow-runtime', async () => { + const result = await runLoader(` + // @flow + export type User = { + name: string + }; + `, { + libraryName: './custom-flow-runtime' + }); + + ok(result.code.includes('import t from "./custom-flow-runtime";')); + }); + + it('keeps imported Flow types available as runtime references', async () => { + const result = await runLoader(` + // @flow + import type {RawDraftContentBlock} from './RawDraftContentBlock'; + + export type RawDraftContentState = { + blocks: Array + }; + `); + + ok(result.code.includes('import { RawDraftContentBlock as _RawDraftContentBlock } from \'./RawDraftContentBlock\';')); + ok(result.code.includes('const RawDraftContentBlock = t.tdz(() => _RawDraftContentBlock);')); + ok(result.code.includes('export const RawDraftContentState = t.type("RawDraftContentState"')); + }); + + it('supports legacy webpack query options', async () => { + const result = await runLoader(` + // @flow + export type User = { + name: string + }; + `, {}, { + getOptions: undefined, + query: '?libraryName=./typed&annotate=false' + }); + + ok(result.code.includes('import t from "./typed";')); + ok(!result.code.includes('t.annotate')); + }); + + it('returns synchronously when no async callback is available', () => { + const code = flowRuntimeLoader.call({ + resourcePath: '/project/types.js.flow', + sourceMap: false, + getOptions: () => ({}) + }, ` + // @flow + export type User = { + name: string + }; + `); + + equal(typeof code, 'string'); + ok(code.includes('export const User = t.type("User"')); + }); + + it('works as a webpack loader for js.flow imports', async () => { + const stats = await compileFixture(); + + ok(stats.hasWarnings() === false); + }); +}); diff --git a/packages/flow-runtime-loader/test-polyfill.js b/packages/flow-runtime-loader/test-polyfill.js new file mode 100644 index 0000000..a2dd837 --- /dev/null +++ b/packages/flow-runtime-loader/test-polyfill.js @@ -0,0 +1,9 @@ +// istanbul ignore next +try { + (new Function('var a = (args) => true; var b = []; b.push(...b);'))(); + require('@babel/register')(); +} +catch (error) { + // Legacy environment. + require('@babel/register'); +} diff --git a/packages/flow-runtime-loader/test/fixtures/webpack/entry.js b/packages/flow-runtime-loader/test/fixtures/webpack/entry.js new file mode 100644 index 0000000..df7a223 --- /dev/null +++ b/packages/flow-runtime-loader/test/fixtures/webpack/entry.js @@ -0,0 +1,11 @@ +import {Post} from './types.js.flow'; + +const value = { + id: 'post_1', + title: 'Hello', + tags: ['flow', 'runtime'] +}; + +Post.assert(value); + +export default value; diff --git a/packages/flow-runtime-loader/test/fixtures/webpack/flow-runtime.js b/packages/flow-runtime-loader/test/fixtures/webpack/flow-runtime.js new file mode 100644 index 0000000..34c7559 --- /dev/null +++ b/packages/flow-runtime-loader/test/fixtures/webpack/flow-runtime.js @@ -0,0 +1,12 @@ +const t = { + array: type => ({assert: value => value, type}), + property: (key, type) => ({key, type}), + string: () => 'string', + type: (name, body) => ({ + assert: value => value, + body, + name + }) +}; + +export default t; diff --git a/packages/flow-runtime-loader/test/fixtures/webpack/types.js.flow b/packages/flow-runtime-loader/test/fixtures/webpack/types.js.flow new file mode 100644 index 0000000..e54ecf7 --- /dev/null +++ b/packages/flow-runtime-loader/test/fixtures/webpack/types.js.flow @@ -0,0 +1,7 @@ +// @flow + +export type Post = { + id: string, + title: string, + tags: Array +}; diff --git a/packages/flow-runtime-loader/test/mocha.opts b/packages/flow-runtime-loader/test/mocha.opts new file mode 100644 index 0000000..c7c549e --- /dev/null +++ b/packages/flow-runtime-loader/test/mocha.opts @@ -0,0 +1,4 @@ +--reporter=dot +--require @babel/polyfill +--require ./test-polyfill.js +src/**/*test.js