Browser first: the one rule behind every tool on KitDev Space
Browser first: the one rule behind every tool on KitDev Space
浏览器优先:KitDev Space 每个工具背后的唯一准则
A while ago I caught myself pasting a JWT from a production API into the first “JWT decoder” Google gave me. I wanted to read the claims. I didn’t stop to think about where the token went, and honestly, neither does anyone else. We do the same with .env files in JSON converters and with photos in EXIF strippers. The site has a privacy policy that says the server keeps nothing, and we take its word for it. 前段时间,我发现自己把生产环境 API 的 JWT 粘贴到了谷歌搜索到的第一个“JWT 解码器”里。我只是想查看其中的声明(claims)。我当时没停下来思考这个令牌会被发送到哪里,老实说,其他人也不会去想。我们在使用 JSON 转换器处理 .env 文件,或者用 EXIF 清除工具处理照片时,也是如此。这些网站通常有一份隐私政策声称服务器不会保存任何数据,而我们也就信以为真了。
That moment is roughly why KitDev Space exists. I wanted a set of everyday developer tools where I didn’t have to take anyone’s word for it, including my own. The way I got there was less about the tools and more about one rule I wrote down early and then refused to bend. This post is about that rule. 那一刻正是 KitDev Space 诞生的原因。我想要一套日常开发者工具,在使用时不必轻信任何人,包括我自己。我实现这一目标的过程,与其说是关于工具本身,不如说是关于我早期写下并拒绝妥协的一条准则。这篇文章就是关于这条准则的。
The rule: Run the work in the browser. Use a server only when the browser genuinely can’t do the job. The first half is easy to agree with. The second half is where it gets interesting, because “can’t” has to mean something concrete or every tool ends up on the server for convenience. 准则:在浏览器中完成工作。只有当浏览器确实无法胜任时,才使用服务器。前半部分很容易达成共识。而后半部分则更有趣,因为“无法胜任”必须有具体的定义,否则为了方便,每个工具最终都会被搬到服务器上。
So I wrote down the situations that actually qualify, and the list ended up shorter than I expected: 因此,我列出了真正符合条件的场景,结果发现列表比我预期的要短:
- The work needs a native library. Encoding AVIF or rasterizing an SVG needs a real image codec. Opening a TLS handshake needs a socket. 工作需要原生库。 编码 AVIF 或栅格化 SVG 需要真正的图像编解码器。发起 TLS 握手需要 Socket 连接。
- The work needs to talk to another host. DNS records, HTTP headers, RDAP, OpenGraph previews. The browser can’t make those requests because of CORS, and it shouldn’t be able to. 工作需要与外部主机通信。 例如 DNS 记录、HTTP 标头、RDAP、OpenGraph 预览。由于 CORS(跨域资源共享)限制,浏览器无法发起这些请求,而且它也不应该具备这种能力。
- The work needs a runtime API with no browser equivalent, like Bun.dns or Bun.Archive. 工作需要浏览器中没有对应功能的运行时 API,例如 Bun.dns 或 Bun.Archive。
- The input is too large for a browser tab to hold comfortably. 输入数据过大,浏览器标签页无法轻松处理。
If a tool doesn’t hit one of those, it stays in the browser. I’ve been tempted to make exceptions a few times, usually when the server version would have been twenty lines and the browser version two hundred. I haven’t yet. 如果一个工具不符合上述任何一点,它就必须留在浏览器中运行。我曾几次想过破例,通常是因为服务器版本只需 20 行代码,而浏览器版本需要 200 行。但我至今没有这样做。
The browser does more than I gave it credit for
浏览器的能力超乎我的想象
Going in, I assumed I’d be writing a lot of server code. I was wrong about that. Most of what a developer tool site does is already covered by platform APIs or by a small dependency that runs fine in a tab. 起初,我以为自己会编写大量的服务器代码。但我错了。开发者工具网站的大部分功能,其实早已被平台 API 或可以在标签页中良好运行的小型依赖库所覆盖。
| Work | What handles it in the browser |
|---|---|
| SHA-1, SHA-256, SHA-384, SHA-512 | crypto.subtle.digest |
| Random bytes, UUIDs | crypto.getRandomValues |
| AES-256-GCM, PBKDF2, HMAC | crypto.subtle |
| Resize, rotate, crop | OffscreenCanvas, createImageBitmap |
| JPEG, PNG, WebP encode | OffscreenCanvas.convertToBlob |
| Reading EXIF and GPS metadata | a DataView over the file bytes |
| Gzip, deflate, tar | fflate |
| YAML, TOML, JSONC, JSON5 | confbox |
| SQLite | sql.js (WebAssembly) |
| 工作内容 | 浏览器处理方式 |
|---|---|
| SHA-1, SHA-256, SHA-384, SHA-512 | crypto.subtle.digest |
| 随机字节, UUIDs | crypto.getRandomValues |
| AES-256-GCM, PBKDF2, HMAC | crypto.subtle |
| 缩放, 旋转, 裁剪 | OffscreenCanvas, createImageBitmap |
| JPEG, PNG, WebP 编码 | OffscreenCanvas.convertToBlob |
| 读取 EXIF 和 GPS 元数据 | 对文件字节使用 DataView |
| Gzip, deflate, tar | fflate |
| YAML, TOML, JSONC, JSON5 | confbox |
| SQLite | sql.js (WebAssembly) |
So the JWT debugger verifies HS256 signatures with Web Crypto and never sees a server. The AES tool derives its key with PBKDF2 in your tab. The tar explorer opens an archive without uploading it. The SQLite studio runs an actual database in memory. None of that required anything clever, just a willingness to check what the platform already had before reaching for a route. 因此,JWT 调试器使用 Web Crypto 验证 HS256 签名,完全无需经过服务器。AES 工具在你的标签页中通过 PBKDF2 派生密钥。Tar 浏览器无需上传即可打开归档文件。SQLite 工作室在内存中运行真实的数据库。这些都不需要什么高超的技巧,只需要在考虑后端路由之前,先确认平台本身是否已经提供了相应功能。
Web Crypto does have gaps. There’s no MD5, no CRC32, no xxHash. I could have pulled in a JavaScript MD5 library and kept everything client side, but the hash tool also offers xxhash64 and wyhash, and shipping all of that to the browser for algorithms most people rarely use felt wrong. So the hash generator keeps a server path for those, and the page says so in plain words when you pick one. I’d rather tell you which option leaves your machine than pretend nothing does. Web Crypto 确实存在局限。它没有 MD5、CRC32 或 xxHash。我本可以引入一个 JavaScript MD5 库来保持所有功能都在客户端运行,但哈希工具还提供了 xxhash64 和 wyhash,为了这些大多数人很少使用的算法而向浏览器发送所有代码感觉并不妥当。因此,哈希生成器为这些算法保留了服务器路径,当你选择它们时,页面会明确告知。我宁愿告诉你哪些选项会离开你的机器,也不愿假装什么都没发生。
One module, two paths
一个模块,两条路径
A tool with both a browser path and a server path is a maintenance trap waiting to happen. Two implementations, one of them quietly drifts, and now the same input gives different output depending on where it ran. I got bitten by exactly this on the data converter early on. 一个同时拥有浏览器路径和服务器路径的工具,是一个随时可能爆发的维护陷阱。两种实现方式,其中一种悄悄发生偏差,导致相同的输入在不同环境下产生不同的输出。我在早期的“数据转换器”上就吃过这个亏。
The fix I settled on: each shared module exports the function that does the work plus a small guard that answers “can this run here?”. The page checks the guard. The server route imports the same module. The code that actually hashes a file exists once. 我最终确定的解决方案是:每个共享模块都导出一个执行工作的函数,以及一个回答“这里能运行吗?”的小型守卫函数(guard)。页面会检查这个守卫,而服务器路由则导入同一个模块。真正执行哈希计算的代码只存在一份。
// shared/utils/crypto/hash.ts
const SUBTLE_NAMES = { sha1: 'SHA-1', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512' }
export function canHashInBrowser(algorithm: HashAlgorithm): boolean {
return algorithm in SUBTLE_NAMES
}
// simplified page logic
const result = canHashInBrowser(algorithm)
? await hashInBrowser(input, algorithm)
: await $fetch('/api/hash', { method: 'POST', body: { input, algorithm } })
Hashing, data format conversion, code formatting and semver all use this shape now. When a new tool needs both paths, it gets the same treatment, and I don’t have to think about it anymore. 现在,哈希计算、数据格式转换、代码格式化和语义化版本号(semver)都采用了这种模式。当新工具需要两条路径时,它也会得到同样的待遇,我再也不用为此费心了。
What the server is actually for
服务器的真正用途
The tools that do need a server run on Bun. Each one is there because of one of the four reasons above, and I can tell you which: 那些确实需要服务器的工具运行在 Bun 上。每一个工具的存在都是因为上述四个原因之一,我可以告诉你具体是哪一个:
- The TLS Certificate Inspector opens a socket and reads the chain, the SANs and the cipher suite. TLS 证书检查器打开 Socket 并读取证书链、SAN 和密码套件。
- DNS Lookup and the Email Health Inspector query A, MX, TXT, SPF, DKIM and DMARC records. DNS 查询和电子邮件健康检查器查询 A、MX、TXT、SPF、DKIM 和 DMARC 记录。
- The HTTP Inspector, OpenGraph Previewer and RDAP Lookup all fetch from a third-party host. HTTP 检查器、OpenGraph 预览器和 RDAP 查询都从第三方主机获取数据。
- Image Studio and the Favicon Set Generator use Bun.Image for AVIF and ICO output. 图像工作室和 Favicon 生成器使用 Bun.Image 进行 AVIF 和 ICO 输出。
- The TS/JSX Transpiler and the AST Playground run OXC, which is native code. TS/JSX 转译器和 AST 演练场运行 OXC,这是原生代码。
Because every server route is a liability, they all go through the same checklist before they ship. 由于每一个服务器路由都是一个潜在的风险点,它们在发布前都必须经过同样的检查清单:
- Validate the URL and allow only http and https. 验证 URL,仅允许 http 和 https。
- Block localhost, private ranges, link-local, cloud metadata hosts, and the IPv6 transition ranges that smuggle an IPv4 address inside (I learned about NAT64 and Teredo the hard way while writing that blocklist). 屏蔽 localhost、私有地址段、链路本地地址、云元数据主机,以及那些隐藏 IPv4 地址的 IPv6 过渡地址段(我在编写该黑名单时,通过惨痛的教训了解了 NAT64 和 Teredo)。
- Restrict the destination port, so the TLS inspector can’t be turned into a port scanner. 限制目标端口,防止 TLS 检查器被滥用为端口扫描器。
- Check content-length, then stream the body and stop at a cap instead of buffering it. 检查内容长度,然后流式传输主体,并在达到上限时停止,而不是进行缓冲。
- Rate-limit by a client key that isn’t derived from x-forwarded-for, since anyone can set that header. 通过不依赖 x-forwarded-for 的客户端密钥进行速率限制,因为任何人都可以伪造该标头。
- Keep uploads in memory and never touch the disk. 将上传内容保留在内存中,绝不触碰磁盘。
- And store no input and no output, period. 并且不存储任何输入和输出,绝对不存。
Analytics follows the same idea. An event carries the tool id and nothing else. I can see that someone opened the cron visualizer today. I have no idea what expression they typed, and I like it that way. 分析功能也遵循同样的理念。事件只携带工具 ID,不包含其他任何信息。我可以看到今天有人打开了 Cron 可视化工具,但我完全不知道他们输入了什么表达式,我喜欢这种方式。