好久没分享前端技术了,今天推荐一个开源软件:Verdaccio,它是一个私有npm仓库。npm是一个基于http的协议,用来存放软件包并且维护版本和依赖,利用http提供的url路径、动词啥的来对软件包进行增删改查。所以Verdaccio这款软件的核心就是实现npm协议。
名词解释:
- verdaccio:一个开源、私有npm服务器软件
- npm:基于http的应用协议,用来存取JavaScript软件包,并提供周边服务
- http:最流行的互联网应用协议,在此之上可以方便、快速地开发app
- htpasswd:一套鉴权机制,通过文本文件存储用户名和密码
verdaccio有一个内置的数据库来存放所有的npm包,除此之外它还有一套默认的鉴权机制:htpasswd。htpasswd鉴权是通过htpasswd文件来存放所有的npm用户,鉴权、添加/删除的时候通过对文件的读写来实现。
很显然htpasswd鉴权机制有许多问题,文件的读写造成内存的浪费,最重要的是,公司内部通常有统一的鉴权服务器。
需要开发一套verdaccio插件来打通两者。除了插件,还需要一个统一的容器来整合verdaccio,插件,和零碎的静态组件,实现的目的是为了能够开箱即用(out of the box)。
登录成功后,用户名和密码通过加密的token(JWT)临时存放在客户端,存放的位置分为:
- 浏览器:存放在localstorage中
- CLI:存放在~/.npmrc下
verdaccio接收到npm请求后,解析出用户名和密码,有选择地向第三方进行认证,除了一些“只读”的操作不用认证,其余npm操作全部向第三方请求认证。认证的缓存时间是120秒,即120秒内重复请求可以免认证。这里我们使用Verdaccio提供的认证插件实现,加以简单的内存缓存即可实现:
代码语言:javascript复制 constructor(config, options) {
this.users = [];
return this;
}
async auth(username, password) {
// 寻找缓存
if (this.users.includes(username)) {
console.log("走的缓存");
return;
}
if (global.$admin[username] === password) {
console.log("走的白名单");
} else {
const resp = await fetch('path/to/authenticator', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!resp.ok) throw "登录失败";
const resData = await resp.json();
if (resData.status !== 200) throw resData.msg;
console.log("走的第三方认证");
}
this.users.push(username);
// 120秒后清空缓存
setTimeout(() => this.users.remove(username), 120 * 1000);
return;
}
对了,以上用到了一个remove方法,它的作用是从列表中删除一个元素,列表长度-1,我们需要提前实现一下:
代码语言:javascript复制// 非纯函数
Array.prototype.remove = function (item) {
const i = this.indexOf(item);
if (i < 0) return false;
else return this.splice(i, 1);
};
Uplinks:上游仓库源
npm install时,上游的包会下载到下游的仓库中国,仓库源的优先级如下:
- Verdaccio server
- registry.npm.taobao.org
- registry.npmjs.org
npm install流程图:
植入自定义前端脚本!
无奈Verdaccio没提供UI扩展机制,我们只能自己动手hack。当然不用阅读源码,利用verdaccio提供的中间件扩展,制作一个ExpressJS中间件插件,在插件中做手脚即可。
思路是这样的:作为一个单页面应用,verdaccio总是会在一开始发送一份index.html给前端,只要在它发送index之后拦截下来,在其中插入一些“恶意代码”再返回给前端就行了。verdaccio中返回index的地方实际有两处,分别是列表页和详情页,对应的url路径分别是“/”和“/-/web/detail/*”。
列表页和详情页的概念真是无处不在
Verdacciol列表页示例
Verdaccio详情页示例
在这两个地方分别拦截2下:第一次是请求方向,匹配到对应的路径后在response对象上标记一下“index”,第二次是返回方向,匹配“index”标记并植入脚本。所以请求方向的中间件代码应该如下:
代码语言:javascript复制 // register_middlewares
app.get(["/", "/-/web/detail/*"], (req, res, next) => {
res.locals["index.html"] = true;
next();
});
既然它用的是Express就没理由不使用response.send方法。我们重写这个方法就能监听到返回的任何数据,但只对index.html类型的数据做修改,返回方向植入代码如下:
代码语言:javascript复制const { response } = require("express");
const { JSDOM } = require("jsdom");
// 扩展send方法,拦截response
const send = response.send;
response.send = function (...params) {
if (this.locals["index.html"]) {
const dom = new JSDOM(params[0]);
dom.window.document.head
.querySelectorAll("link[rel*='icon']")
.forEach((e) => e.remove());
dom.window.document.head.innerHTML = `
<link rel="stylesheet" type="text/css" href="path/to/css.css">
<link rel="shortcut icon" href="path/to/favicon.png"> `;
dom.window.document.body.innerHTML = `
<script src="path/to/js.js"></script> `;
params[0] = dom.serialize();
}
send.bind(this)(...params);
};
成功植入了JS代码就像黑客拿到了webshell,至少在前端可以为所欲为地魔改UI了。虽然共享同一个事件驱动引擎,但你的JS脚本和网页本身的JS脚本逻辑上处于2个不同的“线程”,比如想要寻找一个dom元素,但不知道元素是否健在,是否有延迟等等问题,不知何时去寻找。
对于2个线程之间的博弈,主流的做法是在以下3种突变情况之后,页面稳定的情况下,才可以采取必要行动:
- URL路径变化:利用H5新特性history的pushState/replaceState解决问题
- Dom元素发生变化:利用MutationObserver API来监听body的变化
- 监听网络请求:利用ServiceWorker API来监听前端发送的HTTP请求
因为呢,通常发生以上三种情况的时候,UI才有可能发生变动,从一个稳定期过渡到另一个稳定期。我们可以在此契机下执行我们的回调,避免在稳定期周期执行。
向文件中写入一个浮点数
如果想让前端知道当前web系统的版本号或发行日期,比如package.json中的version字段,好像并没有直接的办法。最省力的做法是每次运行时写入一个前端可读的文本文件,其中记录着当前时间,也可以写入一个8字节的双精度浮点数。为啥不写入正整数?因为JavaScript实数类型默认就是64位浮点数,比较方便而已。代码没什么意思,大家过一下就行:
代码语言:javascript复制// verdaccio运行时
const fsp = require("fs").promises;
fsp
.writeFile(
"path/to/timestamp",
Buffer.from(new Float64Array([new Date().getTime()]).buffer)
)
.catch((err) => {
console.log(err);
process.exit(0);
});
代码语言:javascript复制// 前端脚本
fetch("path/to/timestamp")
.then((res) => res.arrayBuffer())
.then((buffer) =>
console.log("发行时间", new Date(new Float64Array(buffer)[0]))
);
代码语言:javascript复制# .gitignore
path/to/timestamp
前端重构的可行性
我很少推荐前端框架啊,上一次不知道多久以前推荐过一次AgGrid这个表格框架,那倒是纯前端的框架,Verdaccio其实是全栈框架。但是如果你不喜欢Verdaccio默认的UI页面,也可以重写整个前端,然后调用后端接口即可。列表页的接口就是请求所有packages列表,详情页其实就做了两件事儿:一是把README.md展示成HTML,二是把package.json的内容罗列出来,这两点可以参考npmjs.com上面的做法。然后还有基于JWT的token鉴权机制也很简单。所以重写前端很简单,把Verdaccio当作一个后端框架比较舒适。