2026-11-09 16:36:00
Author's note: drafted by Deepseek v4.1 Flash
How small can a browser-to-browser WebRTC handshake get? I spent a weekend measuring it in Chromium 157 (Microsoft Edge) and Safari 26.2. The short answer: 23 bytes per direction, if you're willing to make a few very specific assumptions.
In 2015, Philipp Hancke wrote a great piece called The Minimum Viable SDP. The question was simple: how much do two browsers actually need to exchange before a DataChannel works?
The answer was about 106 characters per direction:
O,<ufrag>,<pwd>,<base64-fingerprint>,<ip>,<port>
That was a hack, and it came with caveats: a TURN relay, no trickle ICE, and a 30-second window to paste the answer. But the core insight was right: most of an SDP is boilerplate, not per-connection state.
I wanted to know what that number looks like today, on current browsers, on a LAN, with no TURN server. So I built a small test harness and measured it.
The result is a nice ladder:
| What you send | Per direction | Assumptions |
|---|---|---|
| Full Chrome SDP | ~568 B | none |
| Full Safari SDP | ~587 B | none |
| Minimal SDP, SHA-256 | 279 B | hand-written SDP, LAN IP known |
| Minimal SDP, SHA-1 | 241 B | same, SHA-1 (experiment only) |
| Parameterized, raw, SHA-256 | 55 B | shared template, LAN IP known, port only |
| Parameterized, raw, SHA-1 | 43 B | same, SHA-1 |
| Pre-shared certificate | 23 B | fingerprint pinned out-of-band |
| Custom ICE stack | 2 B | not browser WebRTC |
The interesting part is not the 23 bytes. It's which 23 bytes, and what you have to give up to get there.
A WebRTC DataChannel is three layers stacked on top of each other:
Each layer needs a small amount of per-connection state:
| Layer | What must be exchanged | Why |
|---|---|---|
| ICE |
ice-ufrag, ice-pwd, candidate (IP + port) |
STUN username and MESSAGE-INTEGRITY |
| DTLS | certificate fingerprint | authenticate the peer's self-signed cert |
| DTLS |
setup role |
who is DTLS client/server |
| SCTP |
sctp-port, max-message-size
|
can be defaulted |
| Signaling | offer/answer role | can be implied by message type |
Everything else in a normal SDP — codecs, rtpmap, fmtp, extmap, msid, ssrc, rtcp-mux, b=AS, BUNDLE, c= — is either irrelevant for a DataChannel or can be hardcoded.
The per-connection random values are remarkably small:
ufrag: 4 characters (24 bits of randomness)pwd: 24 characters in Chrome/Safari (144 bits)That's the whole handshake, information-theoretically.
I used:
ping/pong echo to prove the connection actually works.RTCPeerConnections in the same page, and Chrome page ↔ Safari page.One important detail: modern browsers hide host candidates behind mDNS:
a=candidate:... 1 udp ... <uuid>.local 64062 typ host
Safari always did this in my tests. Chrome headless did too, unless I passed --disable-features=WebRtcHideLocalIpsWithMdns. A web page cannot turn mDNS off. So the "minimal SDP" numbers depend on whether you send the browser's .local candidate or reconstruct a raw IP:port candidate on the receiving side.
For the LAN experiments, I did the latter: the signaling layer knows the peer's IP, so the candidate only needs the UDP port.
The SDP parser is stricter than you'd think, but also more forgiving than the spec suggests.
Mandatory at session level:
v=0
o=- 0 0 IN IP4 0
s=-
t=0 0
v= must be first.o= must have exactly six space-separated fields. The values are not validated.s= must exist and be non-empty.t= must exist.Mandatory per DataChannel m-line:
m=application 9 DTLS/SCTP x
a=ice-ufrag:<4+ chars>
a=ice-pwd:<22+ chars>
a=fingerprint:sha-256 <colon-hex>
The m= line needs at least four fields. DTLS/SCTP is the shortest accepted protocol token. The fourth field can be any token; x is the shortest.
That's basically it. You can delete:
a=mid — Chrome and Safari accept a single m-line without it; Chrome visibly synthesizes a=mid:0 when parsing the remote SDP. (JSEP says include it; cross-browser code should.)a=sctp-port — defaults to 5000.a=setup — offer defaults to actpass, answer to active.a=group:BUNDLE — optional with the default balanced policy.c= — ICE never uses it.a=ice-options:trickle — not required for addIceCandidate.a=max-message-size — defaults to 64 KiB.a=end-of-candidates — ignored by Chrome.a=msid-semantic, a=extmap-allow-mixed — irrelevant for data.A working minimal SDP, SHA-256, LF, with a known LAN IP:
v=0
o=- 0 0 IN IP4 0
s=-
t=0 0
m=application 9 DTLS/SCTP x
a=candidate: 1 udp 1 <LAN_IP> <PORT> typ host
a=ice-ufrag:<UFRAG>
a=ice-pwd:<PWD>
a=fingerprint:sha-256 <SHA256_FP>
That's 279 bytes per direction. Swap SHA-256 for SHA-1 and it's 241 bytes. Drop the candidate and trickle it later, and the first SDP is 191 bytes (SHA-1).
Both Chrome and Safari accepted this template. The Safari-specific surprises:
a=sctp-port cannot be combined with DTLS/SCTP x. If you include a=sctp-port, the m-line must be m=application 9 UDP/DTLS/SCTP webrtc-datachannel, otherwise you get sctp-port attribute can't be used with sctpmap.
.local candidates by default. If you send the browser's candidate verbatim, the SDP is 308/270 bytes (SHA-256/SHA-1). If you reconstruct the candidate with the known LAN IP, it drops to 279/241 bytes.getStats().base64Certificate, so you can compute a SHA-1 fingerprint yourself. Don't use this in production.Chrome ↔ Safari works in both directions at 241 bytes with SHA-1, and 279 bytes with SHA-256.
Once you know the SDP floor, the next step is obvious: don't send the SDP at all. Both sides share the same template, and the wire carries only the values that differ.
sender:
browser creates its normal local SDP (never modify it)
extract ufrag, pwd, fingerprint, candidate
encode them into a compact payload
send
receiver:
decode
rebuild the remote SDP from the shared template
setRemoteDescription(rebuilt SDP)
This is the same idea as the 2015 article, and the same idea as libp2p's WebRTC Direct v2. It also sidesteps the modern SDP munging rules: you never touch the local SDP, so Chrome has nothing to reject.
The ICE alphabet is 64 characters, so each character is exactly 6 bits:
A raw payload with no flags, no lengths, and no prefix is just:
pack6(ufrag) || pack6(pwd) || fingerprint || candidate
With a generic header (flags + lengths + p: prefix), base64url text:
| Mode | SHA-256 | SHA-1 |
|---|---|---|
| full IPv4 | 85 chars / 62 B | 69 chars / 50 B |
| last two octets | 82 chars / 60 B | 66 chars / 48 B |
| port only | 80 chars / 58 B | 64 chars / 46 B |
| trickle (no candidate) | 77 chars / 56 B | 61 chars / 44 B |
Without the header/prefix (raw), assuming a fixed 4/24 profile:
| Mode | SHA-256 | SHA-1 |
|---|---|---|
| full IPv4 | 79 chars / 59 B | 63 chars / 47 B |
| last two octets | 76 chars / 57 B | 60 chars / 45 B |
| port only | 74 chars / 55 B | 58 chars / 43 B |
| trickle | 71 chars / 53 B | 55 chars / 41 B |
Every row in that table was verified with a real DataChannel ping/pong in Chrome, Safari, and Chrome ↔ Safari.
If you're sending binary over a WebSocket or DataChannel, use the byte counts. If you're stuck with text, use the character counts.
The fingerprint is the biggest remaining item: 32 bytes for SHA-256. If both sides already know each other's certificate fingerprint, it doesn't need to be on the wire at all.
Can you just hardcode a certificate in a static page? Not with the standard web API. I checked the API surface in Chrome 157:
new RTCCertificate()
// TypeError: Failed to construct 'RTCCertificate': Illegal constructor
JSON.stringify(cert)
// "{}"
structuredClone(cert)
// true
new RTCPeerConnection({ certificates: [{ getFingerprints: () => [] }] })
// TypeError: Failed to convert value to 'RTCCertificate'
RTCPeerConnection.generateCertificate() is the only factory. There is no import API. You cannot put a PEM private key in a static page and hand it to RTCPeerConnection.
What you can do is generate a certificate once per browser profile, store the RTCCertificate in IndexedDB, and reuse it:
async function getOrCreateCert() {
const db = await openDb();
let cert = await idbGet(db, 'dtls');
if (!cert) {
cert = await RTCPeerConnection.generateCertificate({
name: 'ECDSA',
namedCurve: 'P-256'
});
await idbPut(db, 'dtls', cert);
}
return cert;
}
const cert = await getOrCreateCert();
const pc = new RTCPeerConnection({ certificates: [cert] });
pc.createDataChannel('x');
RTCCertificate is structured-cloneable, so IndexedDB can store it. Chrome and Safari both keep the same fingerprint across page reloads. I did not separately test a full browser restart, but IndexedDB normally persists.
The first pairing still has to exchange the fingerprint somehow: a QR code, a pairing code, a config file, or a normal first connection. After that, both sides cache peerId -> fingerprint and omit it from the wire.
The payload becomes:
ufrag 3 B + pwd 18 B + port 2 B = 23 B
Or 31 characters as base64url.
That's the floor for standard browser WebRTC: 23 bytes per direction, under these assumptions:
mid, sctp-port, setup, and the role are shared/defaulted.You cannot get to zero with standard browser APIs, because ice-ufrag, ice-pwd, and the UDP port are random for every RTCPeerConnection. Only a custom ICE stack can fix those too.
You might be tempted to put one certificate/private key on every device in a "trusted LAN". Please don't.
The sane model is one persistent certificate per device, with fingerprints provisioned or pinned out-of-band. That still gets you the 23-byte handshake, without a single shared secret.
If the wire only carries a port, the receiver needs the peer's IP from somewhere else.
The browser won't help: it hides local IPs behind mDNS. You need the signaling layer or the OS.
Best for a LAN: let the signaling server observe the source address.
function normalizeIp(addr) {
if (!addr) return null;
if (addr.startsWith('::ffff:')) return addr.slice(7);
if (addr === '::1') return '127.0.0.1';
return addr;
}
// HTTP: req.socket.remoteAddress
// ws: req.socket.remoteAddress
Relay that peerIp alongside the signaling message. The receiver rebuilds:
a=candidate: 1 udp 1 <peerIp> <port> typ host
If the signaling server is in the cloud, it sees the peer's public NAT address, not the LAN IP. For a LAN-local signaling server (or direct peer-to-peer signaling), the source address is exactly what you want.
Other options:
127.0.0.1..local candidate and let the browser resolve it. Costs ~30–42 characters.This is why "IP from signaling" isn't cheating: the signaling connection already exists, and its source address is free metadata. The WebRTC payload can therefore be 55 bytes (SHA-256, port-only) or 23 bytes (pre-shared certificate). If you want a self-contained payload with the IP included, add 4 bytes for a full IPv4 address (or 1–2 bytes if you only send a subnet suffix).
Chrome and Safari agreed on more than I expected:
m=application 9 DTLS/SCTP x template.a=mid, a=setup, c=, and a=group:BUNDLE for a single m-line.RTCCertificate in IndexedDB.The differences:
.local; Chrome's can be raw IP with the test flag.a=sctp-port requires UDP/DTLS/SCTP webrtc-datachannel in Safari; DTLS/SCTP x + a=sctp-port is a parse error.getStats() exposes base64Certificate, which is how I computed SHA-1 fingerprints.If you want to be conservative (Firefox, future browser changes), keep a=mid:0, a=sctp-port:5000, and a=setup:actpass/active, and use UDP/DTLS/SCTP webrtc-datachannel. That's about 348 bytes per direction — still tiny compared to a full SDP.
The small numbers are fun, but the assumptions matter:
ice-ufrag/ice-pwd. Chrome 137+ rejects it. Generate locally, read the values, rebuild only the remote SDP.expires, and authenticate the first pairing.If you only remember one number: 23 bytes per direction for a browser-to-browser DataChannel handshake on a LAN, with a persistent certificate and a pinned fingerprint.
If you need to be practical:
| Situation | Use |
|---|---|
| Chrome/Safari, LAN, both ends controlled | raw payload + known IP + port-only: 55 B (SHA-256) / 74 chars |
| Same, with persistent certs | 23 B / 31 chars |
| Must send SDP text, LAN | minimal SDP + SHA-256: 279 B |
| Cross-browser, conservative | add mid + sctp-port + setup: ~348 B
|
| Across NAT | srflx/relay candidate: at least 6 B candidate + 32 B fingerprint + 21 B ICE credentials |
The 2015 article's 106-character answer is still in the right ballpark. What changed is not the information content; it's the dialect (sctpmap → sctp-port), the privacy defaults (mDNS), the SDP munging rules, and the fact that Safari now plays in the same minimal-SDP sandbox.
The most surprising part, to me, is that the browser's own certificate store and IndexedDB let you turn a per-connection DTLS identity into a per-device identity. That's what takes the handshake from 55 bytes to 23 bytes — and it's also the part that deserves the most security thought.
2026-11-05 22:16:00
V站看到 警告!分享淘宝截图可能给你带来危险
于是有个很久的 idea 终于得到实施了。
https://github.com/est/Distill-SVG
把蒸馏成矢量图 SVG ,简称 蒸矢 😃
市面上有很多AI辅助把照片转SVG的,但其实矢量图不适合拿来做这事。GUI这类大量色块的截图倒是非常适合。
发行两个 dmg
DistillSVG-arm64-full.dmg
DistillSVG-arm64-python.dmg
后者需要 pip 装 cv Pillow 等。可以共享已有环境避免重复安装
也可以自己 git clone 命令行操作
转文字通过 macOS 自带 OCR ,图标、头像等会尽可能给你扣出来保留
对于常见的聊天界面加水印够用了。文字识别有问题,你用文本编辑器打开 SVG 改下就行。
如果你说水印藏在头像里,那的确(暂时)还没啥好的办法
目前对于图标太多,需要多处定位的效果很差,蒸矢变蒸💩了。一般的稀疏聊天界面勉强还行
下面是生成的 SVG 样例:
有效果不佳的 case 欢迎提 issue 。只要我 tokens 额度够一定想办法解决 🤣
虽然代码基本都是AI搓的,但也算是我第一个macOS软件作品。
2026-10-12 21:29:00
摊牌了,分享两个极少有人知道的技巧。
观察下面的网页
有没有发现神奇之处
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html [
<!ENTITY my_symbol "★">
<!ENTITY wikipedia '<img height="14" src="https://en.wikipedia.org/favicon.ico" alt="Wikipedia" />'>
]>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Entity test</title>
</head>
<body>
<p>hey &my_symbol; visit &wikipedia; for more!</p>
</body>
</html>
另外就是刚做的 play2mi 有一个特立独行的想法:不让出现厂牌的关键字,一律用图标 代替。
但是这个 <img height="16" src="https://www.mi.com/favicon.ico" /> 出现得次数太多,就很重复了。有没有 宏/macro 来节约呢?
浑身不舒服。经过一番激烈拷打AI,最终结果:
div.wrap param {display: inline; height: 1em; vertical-align: -.125em;}
param[mi] { content: url("https://www.mi.com/favicon.ico"); }
param[mijia] { content: url("https://cdn.web-global.fds.api.mi-img.com/mijia/prod/home/favicon.ico") }
<param mi> 是一个自定义属性,写起来比 <i class="icon-mi"></i>短,也不需要闭合。
用不存在的自定义标签当然也行,但必须写一对儿 <mi-icon></mi-icon>。为了不闭合偷懒,查到浏览器只白名单了14个空元素(Void Elements)
<meta> 声明页面元数据 <meta charset="UTF-8">
<link> 引入外部资源,常用于 CSS、favicon<base> 设置页面中相对 URL 的基础地址 <base href="https://example.com/">
<img> 嵌入图片。它是最常用的空元素之一。<source> 为 <picture>、<audio>、<video> 提供可选媒体源。它通常不能独立发挥作用。<track> 给视频或音频添加字幕、说明文字、章节等文本轨道。<embed> 在页面中嵌入外部内容,例如某些 PDF 文档或浏览器支持的嵌入资源。<br> 强制换行 第一行<br>第二行<hr> 横线<wbr> 建议浏览器可在此处换行 superlong<wbr>word<col> 为表格的一列设置样式或属性 <colclass="numeric"><input> 创建文本框、复选框、单选框、文件选择器等表单控件。<area> 在图片映射(image map)中定义一个可点击区域。<param> 为 <object> 提供参数 已废弃,不建议新项目使用那就变废为宝,拿 <param> 来当 宏 用,不会被当成未知元素,HTML 校验也不报错。
之前的版本还有 css 变量
param {display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; background: var(--icon) center / contain no-repeat;}
param[A] { --icon: url("A.jpg"); }
param[B] { --icon: url("B.jpg") }
也有利用 background-image 比 background 优先
param {display: inline-block; padding: .5em; vertical-align: -.125em; background: center / contain no-repeat;}
param[A] { background-image: url(A.jpg) }
param[B] { background-image: url(B.jpg) }
但 width: 1em 这个太死板。现代css有 content: 更省事儿,宽度还会按图片比例自动算。
AI 还教了一招,用 attr() 给宏传参数
param::after { content: attr(value) }
<param mi value="手机"> <param mi value="电视">
有AI真能学到好多东西,以前翻破书都想不到的,就看你肯不肯开口问。
2026-10-11 16:14:00
还在被 冈易 QQ 音乐困扰吗,让小爱同学播放NAS上的歌需要几步?
https://est.github.io/play2mi/
装个书签,点两下就行。不需要下载软件,不需要第三方服务器
期间还是碰壁了不少的。也学到不少东西。小米音箱以前应该是openwrt魔改 ubus 控制,后来都改成 Android 了。
这一分裂也导致它内部有两套API接口。一个是 sid=micoapi 一个是 sid=xiaomiio,前者跑在 小爱云 Mina api2.mina.mi.com,后者跑在 米家 IoT 云 api.io.mi.com/app 和 api.mijia.tech/app
这次就简单做了 Mina (micoapi) 的支持
能放歌,能 TTS 就行了。要扩展等有缘人吧。
期间搞出来个巨搞笑的插曲,AI 发现浏览器死活登录不上,一度以为是风控了,账号被画像了,IP被标记了,结果是 nonce 计算的时候没用 bigint 精度被截断了。笑死。幸好在去研究 TLS 指纹之前被我拦住了。
这种一般都是低级错误。
然后无聊让AI去扒一下能不能拿到shell,还真有注入漏洞。不过,老固件才行!!气死了!!!!!
算是又填了一个坑吧。对了,这个项目如果想跑在服务器,估计不太行,据说小爱的HTTP对IDC出口有风控。
2026-10-01 20:33:00
想让 <link rel="preconnect"> 强制走 HTTP/3,能做到吗?
<link rel="preconnect" href="https://example.com">
浏览器看到这个标签,会立刻去做这几件事:
example.com 域名等真正要请求资源时,这个连接已经热好了。这就是 提前连接 preconnect
HTTP/3 跑在 UDP 上(QUIC 协议),HTTP/1.1 和 HTTP/2 跑在 TCP 上。
HTML 层面没有任何办法声明"这个 preconnect 要走 h3"。
这个连接最终用什么协议,HTTP/1.1、HTTP/2、HTTP/3,浏览器有一系列决定流程
网络层有两条实现路径(Chromium 正在迁移,两条并存)
服务端某次响应头带上:
Alt-Svc: h3=":443"; ma=86400
浏览器把它记下来(存进 profile 磁盘缓存),之后所有对该站点的 preconnect 都会走 QUIC。
http_stream_factory_job_controller.cc:915-919, 950-968
http_stream_pool_job_controller.cc:283-288
// If the preconnect explicitly requests QUIC, start preconnecting before
// checking existing SpdySession and idle streams.
if (origin_quic_version_.IsKnown()) {
preconnect_callback_ = std::move(callback);
StartAltSvcQuicPreconnect();
}
example.com. IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443
浏览器判定逻辑:
http_stream_factory_job_controller.cc:925-932
http_stream_pool_attempt_manager.cc:1333-1361 → quic_session_pool.cc:1687 SelectQuicVersion()
关键规则:
// net/quic/quic_session_pool.cc:1691
if (metadata.supported_protocol_alpns.empty()) {
// 纯 A/AAAA 记录 → 除非已知 QUIC 版本,否则这个 endpoint 不合格
return svcb_optional ? known_quic_version : Unsupported();
}
DNS 只有 A/AAAA 记录时,浏览器根本不会尝试 QUIC,必须 DNS 里明确写 h3。
force_quic_everywhere(理论上行,Chrome 里做不到)net/quic/quic_context.h:131-133 的 QuicParams 确实能全局强制 QUIC,代码路径也支持:
http_stream_factory_job.cc:145-177(using_quic_ 含 ShouldForceQuic)http_stream_pool_job.cc:43-47(group->force_quic() 时只允许 QUIC)但我把 net/base/switches.h、content_switches.h、chrome_switches.h 都翻了一遍,Chrome 没有任何命令行开关能设置它(老版本的 --force-quic-everywhere 已移除)。只有 Cronet 或自己编译的 embedder 直接构造 QuicContext 才能用。
→ 实际可用方案只有 ① 和 ②。
不考虑 Alt-Svc,细说冷连接直接 h3
example.com. IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443
必要条件,缺一不可:
Chromium 里 NetworkService 用默认配置创建 DNS 解析器:
// services/network/network_service.cc:483
host_resolver_manager_ = std::make_unique<net::HostResolverManager>(
net::HostResolver::ManagerOptions(), ...);
默认 insecure_dns_mode = kDisabled(net/dns/host_resolver.h:354),意思是不走 Chromium 内置的明文 DNS 客户端,只走系统 resolver(getaddrinfo)。而系统 resolver 无法查询 type 65。
// net/dns/host_resolver_dns_task.cc:378
if (types.Has(DnsQueryType::HTTPS)) {
if (!secure() && !client_->CanQueryAdditionalTypesViaInsecureDns(ech_mode_)) {
https_disabled_ = true; // 直接把 HTTPS 查询删掉
types.Remove(DnsQueryType::HTTPS);
}
}
所以要去 chrome://settings/security → 使用安全 DNS,或用企业策略 SecureDnsMode 配自定义 DoH。你用的 DoH 服务器必须支持 type 65(Cloudflare / Google / Quad9 都支持)。
QUIC 不能被关:chrome://flags/#enable-quic 保持默认(Default = 开)。
origin 未被标记 QUIC broken。
不能走代理。源码两处都要求直连:
session_->IsQuicEnabled() && proxy_info_.is_direct()
(http_stream_factory_job_controller.cc:925-932)。代理下 preconnect 只会走 TCP。
查询顺序上 type 65 排第一(kPrioritizeHttpsResourceRecord 默认开),然后才是 AAAA、A。但实际上 DoH 下三个查询是并发发出的(host_resolver_manager_job.cc:864):
// DoH queries can bypass the dispatcher and start all of their transactions immediately.
if (secure) {
while (dns_task_->num_additional_transactions_needed() >= 1) {
dns_task_->StartNextTransaction();
}
}
所以理论上不存在 HTTPS 单独慢的问题。
type 65 来迟了怎么办? 只有 20% × 50ms 的窗口:
// host_resolver_dns_task.cc:1165
timeout = total_time_for_other_transactions * extra_time_percent / 100; // 20%
timeout = std::min(timeout, timeout_max); // 50ms
timeout = std::max(timeout, timeout_min); // 5ms
举例:A/AAAA 用了 30ms,type 65 只多等 6ms。超时就按"没这条记录"处理。
为什么不等更久?因为三个查询并发在飞,type 65 只要不比 A/AAAA 慢太多就来得及。
如果 DNS 里没返回 h3 ALPN,QUIC 侧会返回 ERR_DNS_NO_MATCHING_SUPPORTED_ALPN,然后自动回落到 TCP preconnect:
// http_stream_factory_job_controller.cc:586
if (result == ERR_DNS_NO_MATCHING_SUPPORTED_ALPN && preconnect_backup_job_) {
main_job_ = std::move(preconnect_backup_job_);
main_job_->Preconnect(num_streams_);
所以配错不会报错,只会静默降级成 TCP。 只能靠 netlog 确认。
同样的降级也发生在 type 65 查询超时、SERVFAIL、REFUSED 时 —— IsFatalTransactionFailure() 对 HTTPS 查询永远返回 false,失败时合成一个空的合法响应,效果等同"这条记录不存在"。preconnect 照样成功,只是走 TCP。
唯一的例外是 DoH + UseDnsHttpsSvcbEnforceSecureResponse=true(默认 false),或 ECH 严格模式,此时失败是致命的 —— 因为必须拿到 SVCB 信息才能继续。普通用户碰不到。
非 443 端口不支持。 源码里直接写了注释:
// http_stream_factory_job_controller.cc:113
// Note: Chrome does not yet support different port DNS alpn.
所以 h3 preconnect 实际上只对 443 有效。
ALPN 写了 h2 但没写 h3 反而会堵死 h3。 SelectQuicVersion 一旦看到 SVCB 记录就只认列表里的 h3,找不到直接返回 Unsupported,不会退回"忽略 SVCB 走 QUIC"。也就是说只要发了 HTTPS 记录,就必须在里面写 h3。
HTTPS 记录的 TTL 会拉低整个域名的缓存寿命。 TtlFromInternalResults 取所有查询结果里最短的 TTL(host_cache.cc:729),而 HTTPS metadata 也在这份结果里。别把 HTTPS TTL 单独调小于 A/AAAA,建议保持一致。
ipv4hint/ipv6hint 在稳定版不生效。 代码支持,但受 kUseDnsHttpsSvcbAddressHints 控制,默认关闭(net_base_features.cc:85)。好消息是它不是必需的,A/AAAA 会正常返回 IP。
源码里有"流式返回中间 DNS 结果"的机制(DnsTaskResultsManager),但它的三个开关默认全是关的:
// host_resolver_manager_job.cc:856
if (kEnableIntermediateDnsResults || // 默认关
kAsyncDnsQuicJob || // 默认关
resolver_->IsHappyEyeballsV3Enabled()) { // 默认关
dns_task_results_manager_ = ...;
}
所以当前稳定版 Chrome 实际是"要么全等到齐、要么超时降级",没有真正的流式中间结果。 容易误以为存在该机制。
| 坑 | 说明 |
|---|---|
| 粒度只能是 origin 级 | Alt-Svc 和 SVCB 都是站点级信息。一旦生效,后续真实请求也会一起走 h3;没法只让 preconnect 走 h3 而普通请求走 h2。 |
| UDP 443 被封 | QUIC 握手失败 → 标记 QUIC broken → 之后连 Alt-Svc 都不看,直接退 TCP,还多等一个握手超时。表现为首帧变慢而不是报错。 |
| 代理环境下基本失效 | 方案 B 完全失效(要求直连);方案 A 需要 alt-svc + QUIC 代理同时具备。 |
| 第一次 preconnect 不生效 | 方案 A 必须先有一次真实响应。方案 B 没有这个问题(DNS 直接可查)。 |
| 非 https 目标 |
http:// 永远不会走 h3(GURL::SchemeIsCryptographic 判定)。 |
crossorigin 属性无用 |
<link rel=preconnect crossorigin> 只影响 credentials mode 和连接分组键,不影响协议选择。 |
| 连接数会被合并/限流 | 同一 origin 的多个 preconnect 合并到同一个连接池,受上限约束(kDefaultMaxStreamSocketsPerGroup = 6,http_stream_pool.h:110)。 |
| QUIC 只能开 1 条 | h3 preconnect 只建 1 个 QUIC session。TCP 侧在服务端支持优先级时也会把预连接数压到 1(http_stream_factory_job.cc:255-270)。 |
| QUIC 被禁后不可恢复 | profile 偏好 kQuicAllowed 一旦被企业策略置 false,代码里明确注释 "re-enabling QUIC is not supported",只能重启浏览器或换 profile。 |
# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--log-net-log --net-log-file=/tmp/netlog.json \
--net-log-capture-mode=IncludeSensitive
打开 https://netlog-viewer.appspot.com/ 拖进去,搜:
HTTP_STREAM_JOB 且 type = preconnect_dns_alpn_h3 → 方案 B 生效HTTP_STREAM_POOL_JOB_ALIVE 里 allowed_alpns 含 h3 → 方案 A 或 B 生效QUIC_SESSION / QUIC_VERSION_NEGOTIATION → 确认真的握手了HOST_RESOLVER_DNS_TASK 的 transactions_needed 里有没有 HTTPS 类型 → 没有就说明压根没发这个查询chrome://version → 命令行里没有 --disable-quic
chrome://policy → 没有禁用 QUIC 的企业策略# 直接问你的 DoH(推荐,能验证真实链路)
dig @your-doh-host example.com HTTPS
看 ANSWER 里 alpn= 是否含 h3。注意直接 dig HTTPS example.com 走的是系统 resolver,即使能查到也不代表 Chrome 会用。
| # | 检查项 | 怎么查 |
|---|---|---|
| 1 | DoH 开了吗 |
chrome://settings/security → "使用安全 DNS" 非"关闭" |
| 2 | DoH 服务器支持 type 65 吗 |
dig @doh-host example.com HTTPS 要有 ANSWER |
| 3 | Secure DNS 模式 | 必须"自动"或"安全"。"关闭" = 完全不发 type 65 |
| 4 | 端口是 443 吗 | 非 443 不支持 |
| 5 | ALPN 串里有 h3 吗 | 注意是 h3 不是 h3-30,建议 h3,h3-29,h2,h1
|
| 6 | 是直连吗 | 走代理时 DNS-ALPN-h3 被禁用 |
| 7 | QUIC 没被关吗 | flags 默认 + policy 无禁用 |
| 8 | TTL 合理吗 | HTTPS TTL 别短于 A/AAAA |
| 9 | origin 没被标 QUIC broken | 之前握手失败过会被标记,之后退 TCP 直到重启 |
| 文件 | 作用 |
|---|---|
third_party/blink/renderer/core/loader/preload_helper.cc |
Blink 侧:把 <link rel=preconnect> 变成 preconnect 指令 |
services/network/network_context.cc |
转发给 HttpStreamFactory::PreconnectStreams
|
net/http/http_stream_factory.cc |
传统路径入口,创建 JobController |
net/http/http_stream_factory_job_controller.cc |
核心判定:何时走 DNS-ALPN-H3 / Alt-Svc,失败如何回落 TCP |
net/http/http_stream_factory_job.cc |
单个连接任务,using_quic_ 判定 |
net/http/http_stream_pool_job_controller.cc |
新路径(HEv3)preconnect 逻辑 |
net/http/http_stream_pool_attempt_manager.cc |
新路径:QUIC/TCP 连接尝试、竞速 |
net/quic/quic_session_pool.cc |
SelectQuicVersion():SVCB ALPN 匹配逻辑 |
net/http/http_network_session.cc |
ShouldForceQuic() / IsQuicEnabled()
|
net/quic/quic_context.h |
force_quic_everywhere 等参数定义 |
net/dns/host_resolver.h |
ManagerOptions.insecure_dns_mode(为什么必须 DoH) |
net/dns/host_resolver_dns_task.cc |
HTTPS RR 查询条件、查询顺序、等待超时预算 |
net/dns/host_resolver_manager_job.cc |
DoH 下多查询并发发起 |
net/dns/host_cache.cc |
TtlFromInternalResults():缓存 TTL 取各结果最小值 |
net/dns/public/util.cc |
GetNameForHttpsQuery():非 443 端口的查询域名格式 |
2026-09-26 22:51:00
最近在鼓捣 WebAssembly,这玩意没啥神秘的,可以看出一坨二进制的 .js 库文件
它本身的export和调用方式是统一的,但如今天我才知道,在不同 JavaScript 运行环境加载 .wasm 方式并不完全一样。
例如 Cloudflare Workers 可以直接:
import wasm from "./simple.wasm";
const { instance } = await WebAssembly.instantiate(wasm, imports);
Cloudflare 会把 .wasm 模块作为 WebAssembly.Module 提供给代码。
浏览器通常则是通过 fetch() 获取 Wasm,再使用 WebAssembly.instantiateStreaming():
const { instance } = await WebAssembly.instantiateStreaming(
fetch("./simple.wasm"),
imports,
);
Node.js 则通过文件系统读取 .wasm 二进制,然后初始化。
.instantiate() 第一参数必须是 typed array 或者 ArrayBuffer
我自己写了个 Wasm 库,AI 为 Node、浏览器、Cloudflare Workers 分别写一套初始化代码,8个 .mjs 文件。给我搞懵了。
提了个简化思路:让初始化函数只接受两种东西:string 表示路径,或者cf那种 WebAssembly.Module 对象
然后搞的时候发现,为了兼容 nodejs,必须 import "node:fs" ,才能读 .wasm 文件嘛
然后逆天的 js 引入必须在 top-level ,在 cf 和浏览器侧就必挂。你 typeof process 提前判断一下环境都不行。
不过AI这时候想到一个聪明办法
if (process?.getBuiltinModule) {
const fs = process.getBuiltinModule("fs");
const bytes = await fs.promises.readFile(source);
return WebAssembly.compile(bytes);
}
卧槽,有这么好的办法,你为啥不早点告诉我。
于是使用方式统一成:
Cloudflare Workers
import wasm from "./simple.wasm";
import { init } from "./foo.mjs";
const foo = await init(wasm);
Browser / Node.js
import { init } from "./foo.mjs";
const foo = await init("./simple.wasm");