从 is-promise 事件咱们能够学到什么?

齐云雷,微医云服务团队前端工程师。专一于 Node.js 基础生态建设以及在 Web 应用中的方案沉淀。javascript

前言

4 月 25 日,NPM 社区又一次因更新事故引燃技术圈的讨论,导火索便来自名为 is-promise 的包。前端

网上盛传一个单行代码的包影响到了谷歌、FaceBook、亚马逊等众多大咖的知名项目,也有人扬言它使几乎整个 JavaScript 生态陷入了混乱。java

不过“雪崩”之时,我和身边人都没有体会到震感,不由疑惑,平时不多有场景须要判断某个值是否为 Promise,如此名声不显、功能又不重要的 NPM 包,真的有这么大的影响和破坏力吗?node

既是好奇心的驱使,也是不认同部分夸张的言辞,我决定向前一探究竟。react

is-promise 简介

先解读一下事故发生以前,is-promise 2.1.0 版本的完整代码。git

module.exports = isPromise;
 function isPromise(obj) {  return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'; } 复制代码

这是一个比较宽松的 Promise Like 检查函数,虽然包名叫 is-promise,其实更像 is-thenable。别看只有一行的逻辑,须要不浅的功力才能准确写出。github

例如,前置的 typeof 能有效过滤 String.prototype.then = function () {} 这样不合规范的 thenable 字符串。web

咱们能够不使用,但不应贬低这个包的价值。Promise/A+ 是一个自由的规范,而非语言特性,长久以来有着众多版本实现,采起这种具备包容性的判断方式是合情合理的。npm

相似的 NPM 包还有 Sindre Sorhus 的 p-is-promise,它增长了 catch 方法的检查。json

回顾

让咱们一块儿回到那个周末,从新审视整个事件的始末。

时间线

is-promise 做者 Forbes Lindesay 回顾了当时的主要历程:

  • 2020–04–25T15:03:25Z — 发布存在问题的 2.2.0
  • 2020–04–25T17:16:00Z — Ryan Zimmerman 提交了修复 PR
  • 2020–04–25T17:48:00Z — 在社交软件上收到告警
  • 2020–04–25T17:54:00Z — 合并 Ryan 的 PR,发布 2.2.1
  • 2020–04–25T17:57:00Z — 阅读并关闭 BUG 相关的 issues,从新开了一帖以便集中 沟通
  • 2020–04–25T18:06:00Z — Jordan Harband 提到 "exports" 字段仍然存在 问题
  • 2020–04–25T18:08:08Z — 从 package.json 中移除 "exports" 字段,发布 2.2.2
  • 2020–04–25T19:20:00Z — 撤销 2.2.0 和 2.2.1

可见,做者收到告警信息后的反应是很是迅速的,但撤销操做滞后的问题仍须要指责。

接下来,咱们逐个分析 2.2.x 版本的更迭。

2.2.0

  • 添加 Typescript 声明文件
  • 支持 ES Module 风格的 import

站在上帝视角,咱们明确知道问题出在这里,做者在 package.json 中新增了两个字段

{
 "type": "module",  "exports": {  "import": "index.mjs",  "require": "index.js"  } } 复制代码

很快,就有人反馈 BUG,一共有两类报错

错误一:exports 的文件路径遗漏了 './',在 Node.js 中

Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "index.js" defined in the package config /xxx/node_modules/is-promise/package.json; targets must start with "./"
复制代码

错误二:添加了 type: module,致使 require 被禁用,必须使用 import 才能引入。

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /xxx/node_modules/is-promise/index.js
复制代码

以及被隐藏的错误三:没有更新 package.json 中的 files 字段,致使 index.mjs、index.d.ts 没有一块儿打包发布。

2.2.1

  • 修复错误的 ESM 用法

改动后的 package.json 包含以下

{
 "exports": {  "import": "./index.mjs",  "require": "./index.js"  } } 复制代码

然而,若是使用 require('is-promise/package.json') 引入模块下其余文件,则会抛出

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package.json' is not defined by "exports" in /Users/claude/Workspace/test/is-p/node_modules/is-promise/package.json
复制代码

甚至不容许引用 'is-promise/index' 和 'is-promise/index.js'。

2.2.2

  • 从 package.json 删除 exports 字段

为了完全解决 2.2.0 带来的 Breaking Change,终于在 2.2.2 删掉了 exports 字段。

问题字段解析

本次事故源于两个少见的 package.json 字段,咱们已经见识到了其反作用,但还没搞明白为何会被做者引入,不妨进一步明确它们的概念。

官网文档在 12.x 及以上版本都包含这些字段的描述,可是并不表明 12.x 用户必定享受到了这个特性。

type

它决定当前 package.json 层级目录内文件遵循哪一种规范,包含两种值,默认为 commonjs。

  • commonjs: js 和 cjs 文件遵循 CommonJS 规范,mjs 文件遵循 ESM 规范
  • module: js 和 mjs 文件遵循 ESM 规范,cjs 文件遵循 CommonJS 规范

要正常使用这个特性,在 Node.js v12.x 的早期版本,必须主动开启 --experimental-modules。可是从 v12.16.0 之后就有些混乱,不开启选项的状况下错误使用该字段会当即抛出异常。直到了 v13.2.0 正式引入,取消了实验特性的标识,才算恢复正常。

is-promise 将 type 显式指定为 module,显然会影响到特定版本的 CommonJS 用户。

exports

type 是相对较老的特性,exports 则是鲜有人知。

功能来自 proposal-pkg-exports 提案,以实验特性 --experimental-exports 加入 v12.7.0,于 v12.16.0 正式引入。具体时间线能够经过这个 PR 追溯。

下面看它的具体做用。

一般,咱们用 main 字段指定包的入口文件,但也仅限于指定惟一的入口文件。

exports 字段是 main 的补充,支持定制不一样运行环境、不一样引入方式下的入口文件,也支持导出其余文件,看下面的例子便知。

{
 "main": "./main.js",  "exports": {  ".": "./main.js",  "./feature": {  "browser": "./feature-browser.js",  "default": "./feature.js"  }  } } 复制代码

但值得注意的是,在支持 exports 的 Node.js 版本中,exports 会覆盖 main.js。

exports 一旦被指定,只能引用 exports 中显示导出的文件。

用下面这种特殊写法,才能容许项目内全部文件被导出(未通过充分测试)。 但缺点是没法使用 import isPromise from 'is-promise/index’,而必须带上文件后缀 import isPromise from 'is-promise/index.mjs'

{
 "exports": {  ".": ".",  "./": "./",  } } 复制代码

此外,做者想固然觉得 exports 和 main 字段同样,支持省略 "./",这在文档中并无交代。

做者复盘

过后,做者发布了一篇 《is-promise post mortem》,他公开说明了上述的一部分错误,还总结了导致犯错的几个因素

  • 习惯于本地发布,不通过 CI 验证
  • 使用新特性,CI 却没有添加支持新特性的 Node 版本
  • 只验证了代码,没有验证明际发布到 NPM 的包
  • 本人不在,其余维护者没有途径发布修复补丁

总结下来就两点,测试不充分,流程不规范。

再谈影响

我翻找了相关 ISSUES,发现 create-react-app@angular/clifirebase-tools 等项目的确受到影响,具体表现则为安装、构建失败。

再回看 NPM 生态,is-promise 周下载量在千万级,存在直接引用关系的就有 766 个包(现只剩 561,受事故影响,许多包取消了引用),GitHub 显示依赖它的项目更是有 3.5m 之众。

从问题版本 2.2.0 发布,到 2.2.2 修复,历时约 3 个小时,考虑到 NPM 的缓存机制,实际影响时间会被拉长。

所以,它的影响范围的确很广,但实际没有那么夸张。

一方面,Node.js 12.16.0 之前的 LST 和更早版本才是主流,这些运行时可被认定为安全。

另外一方面,遭到辐射的项目(大多为 CLI 工具)并不具有整个生态的表明性,也不会危及生产环境。

旁观者的思考

看过了问题,也借此反思一下如何避免悲剧发生在本身身上吧。

锁定版本

加锁能够 100% 避免本次意外,尤为面向应用开发者,这是一直在呼吁的工做,却不多真正落地。

不要吐槽 package-lock.json 会本身变,由于只有一个 lock 文件是不成气候的,若是 package.json 没有锁定版本,NPM 会使用浮动的版本覆盖 package-lock.json。

但对于 NPM 包的开发者,除非是对稳定性有所要求的工具链、产品,仍是不建议滥用版本锁定。若是全部的 NPM 包都这么作,必定会加大 node_modules 的混乱程度,也不利于及时享受到相关依赖的修复补丁,反而提升了维护难度。

单元测试

测试的重要性无须多言。

is-promise 的新增更改根本没有获得测试覆盖,甚至连 require 引入都会报错。除了开发者要完善 CI,NPM 是否也有提供内置检测服务的义务呢?

该不应使用小型代码库

小型库背后是众多开源人士的努力贡献,优质的文档、测试用例远超代码的原始价值。

is-promise 的问题不在于它有几行代码,而且代码逻辑没有变动。

我的认为,NPM 包开发者有必要减小依赖数量,应用开发者则能够自由决定。引用也好,套用也罢,但至少请给这些代码的做者和协议应有的尊重。

文档不济

2.2.0 这个版本号的使用是否得当,若是只从功能上看,它是向下兼容 2.1.0 的一次更新吗?

看过上面 exports 字段的介绍能够得知,它固然属于 Breaking Change,但 Node.js 文档的描写是模糊的,让 is-promise 的做者认为 exports 是无害的。

官网通篇没有一个警告字样,若是没有此次事故后才提交的 PR,恐怕会有更多的人掉入坑中。

Yarn or NPM

曾经有很多人倾向于 Yarn 的机制,时至今日,Yarn 和 NPM 的差距已经大大收缩,二者都是不错的选择,我惟一建议是不要混合使用。

Yarn 的速度已经没有特别大的优点

还有像 PNPM 这类致力于改进 NPM 生态的努力,值得咱们持续关注。

总结

当前仍在批判 NPM 生态的人群,大部分不会参与 JS 社区的建设,愿改善现状而贡献的更是百里挑一。

各位 NPM 用户无须危言耸听,人有失手,马有失蹄,只要规范流程,可以有效下降负面影响。

逆耳未必是忠言,但愿更多有价值的声音能被发出。