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.globalshaproxy 设置一个默认值,再加点判断条件,那么就得到了第二个版本:

//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) {
    headers['set-cookie'] = res.headers.getAll('set-cookie')// Workers
} else if (headers['set-cookie']) {
    headers['set-cookie'] = [...res.headers.entries()].filter(header => header[0] === 'set-cookie').map(header => header[1])// Deno / Node.js 18 / Bun
}

别的

  • 注1:这玩意本来应该在两周前就完成了,然而处理 Git 的时候手抖把写了一点的内容全删了……
  • 注2:写文章这天又被workspaces坑了一把,最后被迫回滚,感觉还需要继续学习一个
  • 注3:写这文章的第二天又发现 axios 是通过 xhr 实现请求的,Workers 往哪来的 xhr ……于是又将浏览器环境的 axios 换成了用 fetch api 实现 axios 功能的 redaxios,其实 redaxios 挺好的,gzip后1KB都不到
  • 注4:最后徒手用 fetch(browser/Workers/Deno) 和 node:https(Node.js) 实现了上述的功能……应该彻底结束了
  • 注5:开启 node_compat 的 CloudFlare Workers 是有 process 的,不过可以通过 process.title === "browser" 来判断是否真为 Node.js 环境

参考


评论区