🪂 手动搭建一个 jsDeliver
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
主要是想分享一个可用服务的简单实现,很多的大项目看起来很复杂,想要搭起来也非常不切实际,但是 80% 的稳定性实现 80% 的功能也并不是不能尝试。
个人的小项目中很多地方都会用到各种库,NPM 的库更是亲切,以前爪机时代有很多免费的 CDN 可以白嫖,大厂小厂都觉得公共 CDN 的托管还能帮助提高用户体验,强缓存的策略应该成本可控。但是现在越来越多的托管方删库跑路,再加上云服务的 CDN 的很好体验,慢慢的都不做公共 CDN 的托管了。
但是现在云服务上面的 CDN、边缘计算已经很成熟了,基本上配个源站就能直接上线使用了,极大的提高了现在生产环境的可用性,更稳定、可定制、简单易用。不过今天讲的不是静态资源的分发技术架构,而是通用资源尤其是公共库的线上托管的可行性,毕竟第三方依赖的分离,也能降低部署环节的打包和分发成本。
针对这些公共库,在 jsDeliver 能用的时候,在一些项目中尝试使用了下,体验还是不错的,不需要手动考虑库的部署和可用性问题。可惜的是现在几乎没有放心免费、好用、能用的托管库了。
所以现在尝试做一下:
- jsDeliver
- 为什么好用,解决了什么问题
- 语法是怎么样的
- 我们如果要实现
- 需要考虑哪些问题
- parseUrl 到 文件的映射逻辑
- 包的网络和存储问题
- 统一的实现和管理
- 白名单制度
- 回源和预热机制
- 上生产需要注意什么
- CDN源
- 稳定性和可用性测试
- 缓存和nginx命中策略
- 效率和预热
- fallback 和告警
jsDeliver 之所以好用,主要解决了以下问题:
- npm和GitHub语法:自动同步 NPM 和 GitHub 的对应版本和文件
- HTTPS 支持:默认支持 HTTPS,安全性有保障
- 缓存策略:智能缓存,减少重复请求
- API 简单:CDN URL 直接对应 NPM 包地址
- 无需关心:不需要关心路由和部署,一个URL就能对应具体文件
这样的策略,让在前端开发中,可以通过标签或引用直接使用,不必操心文件从哪里来的问题,在一些快速验证的场景下或者依赖一些外部资源的时候,能够拿来就用。
<!-- NPM 包 --><script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script><!-- GitHub 文件 --><script src="https://cdn.jsdelivr.net/gh/user/repo@main/dist/bundle.js"></script><!-- 直接文件 --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.css">
这个很容易就意识到,这种方便的策略,对应的是内容滥用、安全风险、不可控等问题。这也是很容易理解的,无论是大厂小厂还是监管部门,这种不可控的感觉是一票否决制的。
我倒是觉得这些策略很对胃口,想小范围的使用下呢?直接部署 JSDeliver 的源站?呃呃呃...确实没那么大必要,简单实现起来应该不难,下面就试下。
设计大概目标是自动将我们URL请求的资源,自动下载缓存,然后交给 CDN 节点。核心部分是我们自己服务器的语法解析、代理下载、然后缓存存储。
端侧请求 → CDN边缘节点 → 回源服务器 → URL语法解析 → 代理下载 → 存储本地 → 响应回源
- 资源使用白名单策略,防止滥用
- 下载部分,使用自建的代理,保证安全
- 存储部分,使用本地存储,保证可用性
- 使用路径匹配策略解析包目标
用 fastify 做简单的路由服务
const path = require('node:path')const fastify = require('fastify')const fastifyStatic = require('@fastify/static')const setRouteNpm = require('./routes/npm')const setRouteGH = require('./routes/gh')const server = fastify({ logger: true })// 首页server.get('/', async (request, reply) => {reply.type('application/json').code(200)const list = require('./lib-list/all.json')return {desc: 'only support libs from this list',libs: list,usage: {npm: 'load npm project: https://c.ubug.io/npm/[package]@[version]/[filePath]',gh: 'load github project: https://cdn.jsdelivr.net/gh/[repoOwner]/[repoName]@[archiveTagName]/[filePath]',tagOrVersion: '[version] & [archiveTagName] must specify, cant be lastest/master/dev/...',fileMin: 'files with suffix `js css svg json html? vue`, support [.min] to minify content',filesList: '[filePath] end with folder will list files in that folder',},warning: ['This project is only for personal use, please do not use it for commercial purposes.','No tech support, No service commitment, No features request. Use at your own risk.','Love from UBUG.',]}})setRouteNpm(server)setRouteGH(server)server.setNotFoundHandler({}, function (request, reply) {reply.type('application/json').code(404)return { not: '404', found: request.url }})server.listen({ port: 3003 }, function (err, address) {if (err) {server.log.error(err)process.exit(1)}server.log.info(`server listening on ${address}`)})
const Fastify = require('fastify')const pacote = require('pacote')const fs = require('node:fs');var mime = require('mime-types')const semver = require("semver")const dirList = require('@fastify/static/lib/dirList')const utils = require('../utils')/*** @param {Fastify} server*/module.exports = (server) => {server.get('/npm/:package@version/*', async (request, reply) => {const pkgWithVer = request.params['package@version']const targetRelPath = request.params['*']if (!/@/.test(pkgWithVer)) {reply.code(400)return 'request fail, version not found';}const pkgFilePath = pkgWithVer + '/' + targetRelPathconst pkgFullPath = utils.downloadsNpmPath + '/' + pkgWithVerconst fileFullPath = pkgFullPath + '/' + targetRelPath// 自动下载if (!fs.existsSync(pkgFullPath)) {const pkgName = pkgWithVer.split('@')[0]if (utils.isSupportNpm(pkgName)) {const version = pkgWithVer.split('@')[1]if (!semver.valid(version)) {reply.code(400)return 'request fail, version not valid';}try {const { from, resolved, integrity } = await pacote.extract(pkgWithVer, pkgFullPath, {registry: "https://registry.npmmirror.com",})console.log('extracted!', from, resolved, integrity)} catch (error) {reply.code(500)console.log(error)return 'package is supported, but extract failed!';}} else {reply.code(404)return 'package is not supported';}}if (!fs.existsSync(fileFullPath)) {// min 命令控制const result = await utils.minifyCode(fileFullPath, pkgFilePath)if (result == 404) {reply.code(404)return 'package is supported, but the file you request is not found!';} else if (result == 500) {reply.code(500)return 'minify source fail';}}// 文件列表功能if (fs.lstatSync(fileFullPath).isDirectory()) {dirList.send({reply,dir: fileFullPath,options: {render: utils.listRender('npm', pkgFilePath),format: 'html',},route: pkgFilePath,prefix: '',dotfiles: "deny",})return reply} else {reply.code(200)// 返回正确的响应,以便能在浏览器中直接使用.type(mime.lookup(fileFullPath))// 控制缓存,因为是带有版本号的库文件,可以强制缓存很长时间.header('Cache-Control', 'max-age=31104000') // 360 天的强缓存utils.setHeaders(request, reply)return fs.createReadStream(fileFullPath, 'utf8')}})}
const Fastify = require('fastify')const gitly = require('gitly')const fs = require('node:fs');var mime = require('mime-types')const dirList = require('@fastify/static/lib/dirList')const utils = require('../utils')const ghDownload = async (repoWithTag, repoFullPath) => {let tryProxys = [(originUrl) => `/to/your/proxy/${originUrl}`,(originUrl) => `/to/your/proxy2/${originUrl}`,]let isOK = falsefor (let i = 0; i < tryProxys.length; i++) {if (isOK) return;const proxyFilter = tryProxys[i];try {const [source, destination] = await gitly.default(repoWithTag.replace('@', '#'), repoFullPath, {throw: true,url: {filter: (info) => {const { path: repo, type } = infoconst originUrl = `https://github.com${repo}/archive/${type}.tar.gz`const proxyUrl = proxyFilter(originUrl)console.log(proxyUrl)return proxyUrl}},})isOK = true} catch (error) {console.log(error)}}if (!isOK) {throw Error('gitly fetch repo fail')}}/*** @param {Fastify} server*/module.exports = (server) => {server.get('/gh/:user/:repo@tag/*', async (request, reply) => {const repoWithTag = request.params['user']+'/'+request.params['repo@tag']const targetRelPath = request.params['*']// 因为强缓存,所以 master/dev 之类的分支名不可以if (!/@/.test(repoWithTag)) {reply.code(400)return 'request fail. tag not found';}const repoFilePath = repoWithTag + '/' + targetRelPathconst repoFullPath = utils.downloadsGHPath + '/' + repoWithTagconst fileFullPath = repoFullPath + '/' + targetRelPath// 自动下载if (!fs.existsSync(repoFullPath)) {const repoName = repoWithTag.split('@')[0]if (utils.isSupportGH(repoName)) {const tag = repoWithTag.split('@')[1]// 必须指定 tag,不能是默认的 master,必须是 tagif (tag === 'master') {reply.code(400)return 'request fail, tag not valid';}try {await ghDownload(repoWithTag, repoFullPath)} catch (error) {reply.code(500)console.log(error)return 'package is supported, but extract failed!';}} else {reply.code(404)return 'package is not found';}}if (!fs.existsSync(fileFullPath)) {// min 命令控制const result = await utils.minifyCode(fileFullPath, repoFilePath)if (result == 404) {reply.code(404)return 'package is supported, but the file you request is not found!';} else if (result == 500) {reply.code(500)return 'minify source fail';}}// 文件列表功能if (fs.lstatSync(fileFullPath).isDirectory()) {dirList.send({reply,dir: fileFullPath,options: {render: utils.listRender('gh', repoFilePath),format: 'html',},route: repoFilePath,prefix: '',dotfiles: "deny",})return reply} else {reply.code(200)// 返回正确的响应,以便能在浏览器中直接使用.type(mime.lookup(fileFullPath))// 控制缓存,因为是带有版本号的库文件,可以强制缓存很长时间.header('Cache-Control', 'max-age=31104000') // 360 天的强缓存utils.setHeaders(request, reply)return fs.createReadStream(fileFullPath, 'utf8')}})}
直接 nginx 部署即可,这里就不多说了。
通过以上方案,我们可以搭建一个稳定可靠的 CDN 服务,虽然功能不如 jsDeliver 完善,但足以满足一般的日常需求。
具体的使用场景是在一些小的页面级别应用中使用,快速验证、快速部署,不需要太复杂的打包优化和部署流程。
在阅读这篇关于手动搭建 CDN 的文章前,希望你能理解:
- CDN 的核心价值 - 将内容分发到最近的节点,加速访问速度
- 开源 CDN 的重要性 - 解决公共库访问不稳定的问题
- 技术实现的权衡 - 在功能、成本和稳定性之间找到平衡
- 运维的复杂性 - CDN 不仅仅是技术问题,还有运维和成本考量
- 自建 vs 使用 - 根据团队规模和需求选择合适的方案
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/setup-cdn-for-npm-and-gh)