添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
Blog

整一个同时用于浏览器和 Node.js 的模块

2023-03-28

#npm
#Nodejs
#CloudFlare Workers
#Deno
#Rollup

前段时间研究了几个比较常用的浏览器翻译源以后,我写了 translator-utils 的 最初版本 ,后来打算将它从大的 monorepo 拆分出来时,麻烦就来了

引入依赖

上一篇文章 我也提到过,有一部分的翻译源的链接是允许跨域的,所以在把这玩意拆分出来的同时我准备打一个 umd bundle 出来给浏览器直接引入。然而,我使用的 Axios 并没有自带用于代理请求的 Http(s) Agent,于是我用了 hpagent 模块,而这个模块使用的 Agent 是 Node.js 的 http / https 模块独有的,因此并不能直接用于浏览器环境,这时就需要想办法绕过。

动态导入❌

在看完 前人留下的文章 后,我先想到的是用动态引入的办法绕过,于是有了第一版:

...
//为什么要套一个async函数,因为UMD不支持 top level await 啊
const axiosConfig = async () => {
    if (typeof process !== 'undefined') {
        const https_proxy = process.env.https_proxy ?? process.env.HTTPS_PROXY ?? ''
        const http_proxy = process.env.http_proxy ?? process.env.HTTP_PROXY ?? ''
        const { HttpProxyAgent, HttpsProxyAgent } = await import('hpagent')
        axios.create({
            httpsAgent: HttpsProxyAgent({proxy: https_proxy})
            httpAgent: HttpProxyAgent({proxy: https_proxy})

显而易见地,这种操作本身没啥问题,放到一个新建的 vite 工程里也能用,然而到了打UMD包的时候就会提示缺少依赖,并且正式丢进浏览器中后就会发现完全不能用,所以需要换一种思路

(!) Missing shims for Node.js built-ins Creating a browser bundle that depends on "https", "http" and "url". You might need to include https://github.com/FredKSchott/rollup-plugin-polyfill-node

另外在研究这种操作时我看到了 一条issue

导入空 Object ✔

反复翻阅 Rollup 的文档后,我发现可以通过 external 先将 haproxy 变成一个从外部引入的模块,再通过 output.globals haproxy 设置一个默认值,再加点判断条件,那么就得到了第二个版本:

//rollup.config.js
export default [{
    input: 'src/index.browser.ts',
    output: {
        file: 'dist/translator.js',
        format: 'umd',
        sourcemap: true,
        name: "translator",
        exports: "default",
        globals: {
            hpagent: '{}'
    external: ['hpagent'],
    plugins: [
        commonjs(),
        typescript({tsconfig: './tsconfig.rollup.json'}), 
        resolve({browser: true}), 
        peerDepsExternal('./package.json'),
        babel({
            babelHelpers: 'bundled',
            presets: ['@babel/preset-env']
//axios.config.js
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"
const axiosConfig = (config) => {
    if (typeof process !== 'undefined' && HttpProxyAgent && HttpsProxyAgent) {
        //这里面跟上一版基本一致

虽然确实能用,但这玩意属于奇技淫巧……不能称作优雅

Workspaces ✔✔

Workspaces 是 npm 7.x+ / Yarn 支持的功能,可以用来写自己的模块,而npm引入模块时是可以根据 package.json 的键 exports 区分入口的,于是我搞了第三版:

//package.json
    "workspaces": [
        "packages/*"
//packages/translator-utils-axios-helper/package.json
    "name": "translator-utils-axios-helper",
    "version": "0.0.1",
    "private": true,
    "main": "index.node.js",
    "types": "index.node.d.ts",
    "exports": {
        ".": {
            "browser": {
                "default": "./index.js"
            "default": {
                "default": "./index.node.js"
        "./package.json": "./package.json"
    "type": "module"
//packages/translator-utils-axios-helper/index.js
import axios from 'axios'
const axiosFetch = axios.create({})
export default axiosFetch
//packages/translator-utils-axios-helper/index.node.js
import axios from 'axios'
import { HttpsProxyAgent } from "hpagent"
const axiosFetch = axios.create({ httpsAgent = new HttpsProxyAgent({proxy: HTTPS_PROXY}) })
export default axiosFetch
//other scripts
import axiosFetch from 'translator-utils-axios-helper'

这下就彻底分了家,后面可以通过插件 @rollup/plugin-node-resolve 来区分入口

运行环境

这就完了吗,那确实还没有,经过一番操作我彻底删掉了axios,然后麻烦又来了:不同的运行环境都会有一些独有的方法,这时候就需要挨个适配了

node:https

既然去掉了axios,那后续的操作自然就是给原生的https库套Promise,这没啥好说的,也就只有 data 事件并非一次性读完全部内容可能会被忽略掉(也只有我这种没接触过的会被坑了吧)

new Promise((resolve, reject) => {
    const req = https.request(url, options, (res) => {
        let chunkArray = []
        res.on('data', (data) => {
            chunkArray.push(data)
        res.on('close', () => {
            resolve(Buffer.concat(chunkArray))
    req.on('error', (e) => {
        reject(e)
    req.end()

fetch

浏览器/Workers/Deno/Node.js 18.x 所自带的 fetch() 都自带一系列的转换方法,不需要像 node:https 那样手操 Buffer (想硬干 也不是不行 ),然而 fetch 看似轻松简单,实际暗藏玄机……平时在浏览器经常用 fetch,但很少用到headers,于是也忽略了这玩意

正好这次需要拿cookie,而 Set-Cookie 是一个比较特殊的存在,是唯一允许重复使用的标头,而 fetch.headers 的类型 Headers 本质上就是个 Map ,因此会常规的 fetch.headers.get('set-cookie') 只会得到第一个 set-cookie 的值,因此不同的环境都做出了自己的解决方案

  • 浏览器: 根本没有! 所以不需要管,我直接摘抄MDN的描述:

    警告: 根据 Fetch 规范,Set-Cookie 是一个 禁止的响应标头 ,对应的响应在被暴露给前端代码前, 必须滤除 这一响应标头,即浏览器会阻止前端 JavaScript 代码访问 Set-Cookie 标头。

  • Workers:CloudFlare给 set-cookie 一个专有的方法 .getAll() ,将会返回一个数组;我在翻issue过程中发现 github/fetch #236 也是这样操作的
    const res = await fetch('https://www.google.com')
    res.headers.getAll('set-cookie')
    res.headers.getAll('content-type')
    //Uncaught (in promise) TypeError: getAll() can only be used with the header name "Set-Cookie".
    
  • Deno:Deno 通过合并 <Merged> All multiple Set-Cookie headers #5100 终结了这个问题
    const res = await fetch('https://www.google.com')
    res.headers.get('set-cookie')
    //"1P_JAR=2023-03-30-09; expires=Sat, 29-Apr-2023 09:42:39 GMT; path=/; domain=.google.com; Secure, AEC=AUEFqZdWR3Fxxxxxxxxxl6U1vCxxxxSeb6VhSYLLrQ5jzo--xxxg9Hak_g; expires=Tue, 26-Sep-2023 09:42:39 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax, NID=511=Q_yBq0zEyZXlxxxxxcmbHsMPEVo6ytDJCixxxxx_G-nNOtJ-FvnznDxxxxx5ey-WCFP7_ye4DUW_CLC1ok3xxxxxdQ9KXLPh7q7o_bHDQI83sioqoDTPubYdUrLKKiie5L4icxxxxxjUk_hcZNK3bOvr3jeJG3xxxxxAzz_wLI; expires=Fri, 29-Sep-2023 09:42:39 GMT; path=/; domain=.google.com; HttpOnly"
    [...res.headers.entries()].filter(header => header[0] === 'set-cookie').map(header => header[1])
    //  "1P_JAR=2023-03-30-09; expires=Sat, 29-Apr-2023 09:42:39 GMT; path=/; domain=.google.com; Secure",
    //  "AEC=AUEFqZdWR3Fxxxxxxxxxl6U1vCxxxxSeb6VhSYLLrQ5jzo--xxxg9Hak_g; expires=Tue, 26-Sep-2023 09:42:39 GM...",
    //  "NID=511=Q_yBq0zEyZXlxxxxxcmbHsMPEVo6ytDJCixxxxx_G-nNOtJ-FvnznDxxxxx5ey-WCFP7_ye4DUW_CLC1ok3xxxxxdQ9..."
    
  • Node.js:用法同上
  • Bun:同上

上面说了一大堆,总结起来就是除了 Workers 其他环境都是通用的,因此最终我处理这部分的代码就变成

let headers = Object.fromEntries(res.headers.entries())
if (headers['set-cookie'] && res.headers.getAll) {