Repository navigation
perf(net): lazy-load the WebSocket fallback and drop zod from the static graph - #8719
Conversation
Quest-Claim: 98a0ba0f2ca88ef5
…tic graph @moq/qmux now loads on demand when the fallback dials, and transportOf reads a "websocket" label on the qmux Session instead of instanceof. The internal HopSchema becomes a plain branded bigint with toHop(). Every consumer bundle loses 10.5 to 12.9 KB of brotli, which clears Nightly's 30-day size comparison from 2026-12-05 instead of 12-13. Finishes quest/m1/js-bundle-audit.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 9 seconds. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
There was a problem hiding this comment.
ℹ️ No critical issues — two small rough edges worth a look.
Reviewed changes
I reviewed the two bundle cuts in @moq/net, the transport label on qmux's Session, and the mechanical HopSchema → toHop migration. The stats, qmux session, connect, setup, and announce tests pass locally (277 pass).
- Lazy
@moq/qmux:connectWebSocketwaits out WebTransport's head start, then racescancelagainstimport("@moq/qmux"). If cancel wins, nothing loads. If the import fails, only the WebSocket contender rejects, andPromise.anystill lets WebTransport win. transportOfby label: it readstransport("websocket"from qmux,"webrtc"from@moq/p2p) instead ofinstanceof Session.connectInnerandopen.tsshare that one classifier now.zodout of the static graph:HopSchemabecomes a brandedHopplustoHop(), which throwsRangeError. No decode path can reach the throw, sinceu62and the leading-ones varint both stay inside 64 bits.- Quest closed: deletes
quest/m1/js-bundle-audit.md.
ℹ️ zod is still a required peer of @moq/net, but nothing outside @moq/net/zod uses it
After this PR, @moq/net uses zod only as a type, in src/zod.ts (the ./zod subpath). js/net/package.json still lists it as a required peerDependency, so npm 7+ installs zod for every consumer, including ones that never import the subpath. That doesn't change bundle size, but it makes the "zod stays a peer only for the subpath" story incomplete.
Technical details
# Mark zod as an optional peer of @moq/net
## Affected sites
- js/net/package.json `peerDependencies.zod`: still required, though only `src/zod.ts` uses it (`import type * as z from "zod/mini"`)
## Required outcome
- Consumers that don't import `@moq/net/zod` aren't made to install `zod`.
## Suggested approach
- Add `"peerDependenciesMeta": { "zod": { "optional": true } }`, the same way `js/signals/package.json` marks `react` optional for its subpath.claude-opus-5-5 | 𝕏
…'s head start Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Re-reviewed the one commit since the prior Pullfrog review.
- Doc wording fixed:
doc/lib/js/net.mdnow says a moq-lite session WebTransport wins within its head start never downloads the fallback chunks, which matchesconnectWebSocket. This settles the open thread.
The earlier zod optional-peer note is still unapplied. It is informational and doesn't block merge.
claude-sonnet-5-5 | 𝕏
|
quest land watch: the merge queue removed this pull request at 6e61841 because its The land fixer works on it in the background, and the watcher re-enqueues it at the back of the queue once it is fixed or judged flaky, or hands it back here with what it needs.
…y hop it plays
moq-doc check: (pass) audio stretch corpus > silence > moves the output by exactly the shifts it took
moq-doc check: (pass) audio stretch corpus > stereo-linked > matches the checked-in case [0.14ms]
moq-doc check: (pass) audio stretch corpus > stereo-linked > decides every hop it plays
moq-doc check: (pass) audio stretch corpus > stereo-linked > moves the output by exactly the shifts it took
moq-doc check: (pass) audio stretch corpus > stereo-opposite > matches the checked-in case [0.13ms]
moq-doc check: (pass) audio stretch corpus > stereo-opposite > decides every hop it plays
moq-doc check: (pass) audio stretch corpus > stereo-opposite > moves the output by exactly the shifts it took
moq-doc check: (pass) audio stretch corpus > bridge-44100 > matches the checked-in case [0.10ms]
moq-doc check: (pass) audio stretch corpus > bridge-44100 > decides every hop it plays [0.01ms]
moq-doc check: (pass) audio stretch corpus > bridge-44100 > moves the output by exactly the shifts it took [0.03ms]
moq-doc check: (pass) audio stretch corpus > bridge-48000 > matches the checked-in case [0.10ms]
moq-doc check: (pass) audio stretch corpus > bridge-48000 > decides every hop it plays
moq-doc check: (pass) audio stretch corpus > bridge-48000 > moves the output by exactly the shifts it took
moq-doc check: (pass) audio stretch corpus > speed-up-16000 > matches the checked-in case [0.13ms]
moq-doc check: (pass) audio stretch corpus > speed-up-16000 > decides every hop it plays
moq-doc check: (pass) audio stretch corpus > speed-up-16000 > moves the output by exactly the shifts it took
moq-doc check:
moq-doc check: ::endgroup::
moq-doc check:
moq-doc check: 198 pass
moq-doc check: 0 fail
moq-doc check: 24654 expect() calls
moq-doc check: Ran 198 tests across 5 files. [15.16s]
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1662�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1662�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1724�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1724�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1781�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1781�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1828�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1828�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1905�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1905�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[96m../js/net/src/lite/subscriber.test.ts�[0m:�[93m1952�[0m:�[93m51�[0m - �[91merror�[0m�[90m TS2304: �[0mCannot find name 'HopSchema'.
moq-doc check:
moq-doc check: �[7m1952�[0m const subscriber = new Subscriber(quic, version, HopSchema.parse(1n));
moq-doc check: �[7m �[0m �[91m ~~~~~~~~~�[0m
moq-doc check:
moq-doc check: �[91m[error]�[0m Failed to convert one or more packages, result will not be merged together
moq-doc check: 121 | if (app.logger.hasErrors() || app.logger.hasWarnings()) {
moq-doc check: 122 | throw new Error(`TypeDoc: ${app.logger.errorCount} errors and ${app.logger.warningCount} warnings`);
moq-doc check: 123 | }
moq-doc check: 124 | };
moq-doc check: 125 | const project = await app.convert();
moq-doc check: 126 | if (!project) throw new Error("TypeDoc: conversion failed");
moq-doc check: ^
moq-doc check: error: TypeDoc: conversion failed
moq-doc check: at generate (/var/cache/github-runner-work/moq/moq/doc/.vitepress/api/index.ts:126:26)
moq-doc check:
moq-doc check: ::error file=doc/.vitepress/api/index.ts,line=126,col=26,title=error: TypeDoc%3A conversion failed::%0A at generate (/var/cache/github-runner-work/moq/moq/doc/.vitepress/api/index.ts:126:26)
moq-doc check:
moq-doc check: Bun v1.4.4 (Linux x64)
moq-doc check: �[31mfailed to load config from /var/cache/github-runner-work/moq/moq/doc/.vitepress/config.ts�[39m
moq-doc check: �[31mbuild error:�[39m
moq-doc check: Command failed: bun /var/cache/github-runner-work/moq/moq/doc/.vitepress/api/index.ts
moq-doc check: Error: Command failed: bun /var/cache/github-runner-work/moq/moq/doc/.vitepress/api/index.ts
moq-doc check: at genericNodeError (node:internal/errors:986:15)
moq-doc check: at wrappedFn (node:internal/errors:540:14)
moq-doc check: at checkExecSyncError (node:child_process:942:11)
moq-doc check: at execFileSync (node:child_process:978:15)
moq-doc check: at file:///var/cache/github-runner-work/moq/moq/node_modules/.vite-temp/config.ts.timestamp-1794686496503-b870597933cc4.mjs:269:1
moq-doc check: at ModuleJob.run (node:internal/modules/esm/module_job:561:25)
moq-doc check: at async node:internal/modules/esm/loader:647:26
moq-doc check: at async loadConfigFromBundledFile (file:///var/cache/github-runner-work/moq/moq/node_modules/vitepress/node_modules/vite/dist/node/chunks/dep-CkSfqg8D.js:49778:15)
moq-doc check: at async bundleAndLoadConfigFile (file:///var/cache/github-runner-work/moq/moq/node_modules/vitepress/node_modules/vite/dist/node/chunks/dep-CkSfqg8D.js:49621:22)
moq-doc check: at async loadConfigFromFile (file:///var/cache/github-runner-work/moq/moq/node_modules/vitepress/node_modules/vite/dist/node/chunks/dep-CkSfqg8D.js:49584:44)
moq-doc check: Exited with code 1
Traceback (most recent call last):
File "/var/cache/github-runner-work/moq/moq/js/../sh/js/workspace.py", line 263, in <module>
main()
~~~~^^
File "/var/cache/github-runner-work/moq/moq/js/../sh/js/workspace.py", line 258, in main
run(action, plan)
~~~^^^^^^^^^^^^^^
File "/var/cache/github-runner-work/moq/moq/js/../sh/js/workspace.py", line 211, in run
scripts("check")
~~~~~~~^^^^^^^^^
File "/var/cache/github-runner-work/moq/moq/js/../sh/js/workspace.py", line 139, in scripts
command("bun", "run", *concurrency, *filters, "--elide-lines=0", verb)
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/cache/github-runner-work/moq/moq/js/../sh/js/workspace.py", line 131, in command
subprocess.run(args, cwd=cwd, check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/nix/store/xahxrjs30p3mw0a42bqg9ikgkikskw8m-python3-3.14.8/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '('bun', 'run', '--filter=@moq/audio-quality', '--filter=@moq/bench-browser', '--filter=@moq/crypto', '--filter=@moq/demo', '--filter=@moq/demo-boy', '--filter=@moq/demo-carla', '--filter=@moq/hang', '--filter=@moq/interop-browser', '--filter=@moq/interop-native', '--filter=@moq/json', '--filter=@moq/loc', '--filter=@moq/msf', '--filter=@moq/net', '--filter=@moq/p2p', '--filter=@moq/p2p-test', '--filter=@moq/publish', '--filter=@moq/qmux', '--filter=@moq/qmux-interop', '--filter=@moq/receive-credit-test', '--filter=@moq/room', '--filter=@moq/stats', '--filter=@moq/stream-credit-test', '--filter=@moq/video-test', '--filter=@moq/watch', '--filter=moq-doc', '--elide-lines=0', 'check')' returned non-zero exit status 1.
error: recipe `check-changed` failed on line 36 with exit code 1
##[endgroup]
##[error]ci: check js failed
ci: wall and CPU seconds per module
29 40 check biome
89 222 check js (failed)
12 5 check quest
35 27 check shell
1 0 check large_files
2 0 check text
1 0 check toml
5 3 check nix
0 0 check justfile
118 300 whole run
error: recipe `ci` failed on line 119 with exit code 1
##[error]Process completed with exit code 1.
##[group]Run actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: cargo-timings
path: target/cargo-timings/cargo-timing-*.html
if-no-files-found: ignore
retention-days: 14
compression-level: 6
overwrite: false
include-hidden-files: false
archive: true
env:
BASE: 7a4190608da40f53c7c6e493122f4bdc30f5218f
CHECK_FAIL_FAST: 1
NEXTEST_TEST_THREADS: 24
MBX_CACHE_LINKS: 1
MBX_REMOTE_URL:
##[endgroup]
No files were found with the provided path: target/cargo-timings/cargo-timing-*.html. No artifacts will be uploaded.
Post job cleanup.
##[start-action display=Run jdx/mr-boxington-action@88acf2bb50552c7c508cbac13e5f8c576b95b1de;id=__9a779419-ce88-4d31-abdf-a7242ab9e8eb.mbx]
##[end-action id=__9a779419-ce88-4d31-abdf-a7242ab9e8eb.mbx;outcome=skipped;conclusion=skipped;duration_ms=0]
Post job cleanup.
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git version
git version 2.56.0
Temporarily overriding HOME='/var/cache/github-runner-work/_temp/d5d39e1c-6499-4bc4-940d-5ad3f1814ab6' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git config --global --add safe.directory /var/cache/github-runner-work/moq/moq
Removing SSH command configuration
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git config --local --name-only --get-regexp core\.sshCommand
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
Removing HTTP extra header
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
Removing includeIf entries pointing to credentials config files
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
[command]/nix/store/i2m3w4ly2w6grrmk1j59rb7zp09bssia-git-2.56.0/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
A job completed hook has been configured by the self-hosted runner administrator
##[group]Run '/nix/store/3wq9f25mz2ksqrx6zs5nq522386djzw1-ci-cache-gc.sh/bin/ci-cache-gc.sh'
shell: /nix/store/lfm00fnq66xfgbn099vqckabyl5jllz6-bash-interactive-5.3p20/bin/bash --noprofile --norc -e -o pipefail {0}
##[endgroup]
evicted 0 objects and 0 action results (0 B logical); 49.8 GiB logical remain
evicted 0 objects and 0 action results (0 B logical); 49.8 GiB logical remain
Cleaning up orphan processes
(written by quest land watch) |
|
codex, gpt-6.1-sol, medium effort land fixer note:
(written by codex, gpt-6.1-sol, medium effort) |
|
quest land watch: armed auto-merge on this pull request at daf3f69, so it joins the back of the merge queue once its required checks pass, with the land fixer's fix ( (written by quest land watch) |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
I re-reviewed the two commits added since the last Pullfrog review.
- Merged
main: brought in six new tests injs/net/src/lite/subscriber.test.ts. - Finished the
toHoprename: changedHopSchema.parse(1n)totoHop(1n)in those six tests. Nothing underjs/usesHopSchemaany more.
claude-opus-5-5 | 𝕏

Finishes quest/m1/js-bundle-audit.md.
Problem
Nightly's 30-day size comparison (10%) fails from 2026-11-21 until a post-growth report becomes its anchor on 12-13. moq-dev/moq#8654 accepted the growth as feature growth and asked for an audit of what each consumer bundle carries, landing the non-breaking cuts. From 12-07 (the 11-07 anchor),
net(1.119x) andhang(1.105x) still failed.Approach
Two cuts. Both reach every bundle because every bundle carries
@moq/net:@moq/qmuxloads on demand. The WebSocket fallback's QMux stack was in every first load, though a session over WebTransport never uses it.connectWebSocketnow dynamic-imports it once WebTransport's head start runs out, so a session WebTransport wins never downloads it. With no head start (no WebTransport, or WebSocket won the last race to this server), the import starts at once.transportOfno longer usesinstanceof Session. It reads atransport = "websocket"label on the qmuxSession, the same way@moq/p2p's transport labels itself"webrtc". This also classifies a qmux session a caller passes in throughtransport.zodleaves@moq/net's static graph. Its only runtime use was the internalHopSchema, a refined, branded bigint. It is now a branded type plustoHop(), which throws the same message as aRangeError.zodstays a peer for the@moq/net/zodsubpath, which only uses its types.Audit
Static (first-load) chunks, from the
bundles.tsmetafiles. Marginal brotli means the bundle with only that package left external.netjs/net/dist446 KB (track56,lite/subscriber45,lite/publisher30,origin30,resume26,group18),js/qmux44 KB,pako42 KB,js/pattern14 KB,zod9 KBhangnet+zod37 KB +js/hang36 KB (container/consumer11 KB)@moq/hang/catalog), which watch parses before playback. Making them lazy would need an async, breaking catalog API to save 9 KB, so no m2 quest.watch,watch-uihang+js/watch123 KB (playerchunk 68 KB,ui/element43 KB,element12 KB)publishhang+js/publish71 KB (encoderchunk 51 KB,element11 KB)mediabunnyis already lazy.pakostays.CompressionStreamcan't do moq-flate's job: it is async, it can't flush a sync slice per frame, and it can't stop a bomb partway through a chunk. fflate would be 4.2 KB brotli against pako's 11.8 KB. But its streamingInflatebuffers all of a push's output before the size check, which loses the decoder's bound (declaration plus one 4 KB chunk). It also copies the 32 KB window on every push. fflate'sDeflate(3.3 KB) next to pako'sInflate(9.2 KB) would be 12.5 KB, more than pako alone (11.8 KB).Before and after (
bun sh/size/bundles.ts, brotli bytes)main97a7267)nethangwatchwatch-uipublishRaw size falls 45 to 53 KB and gzip 12.7 to 15.6 KB per bundle. The lazy qmux chunk is outside the first load, which is all Nightly measures.
30-day comparison
Replaying the retained
nightly-size-history(11-13 artifact) against these bytes:netand cuts inhang,watch, andwatch-ui, and no non-breaking cut that size is left. As Nightly artifact sizes says, the comparison is not waived or reset.Impact
@moq/qmux:Sessiongainsreadonly transport: "websocket"(additive).@moq/net: no public API change. The WebSocket fallback is now a separate chunk loaded on demand, like the moq-transport stack already is. A bundler has to emit dynamic-import chunks, which it already must for the moq-transport stack.@moq/netneeds a@moq/qmuxthat carries the label; theworkspace:^range pins that at release.Validation
bun sh/size/bundles.tsbefore and after, as in the tables. The metafile check shows nojs/qmuxinput in any first-load chunk.dist. qmux landed in its own chunk. Ran it in Node (no WebTransport) against a localmoq-relayfrom this tree. Both sessions reportedtransport: websocket, connected via WebSocket, and a frame round-tripped through the relay.Sessionlabels its transportwebsocket.stats.test.tsnow marks the fallback mock by that label.doc/lib/js/net.md,just checkpasses.just cipasses (biome,just js checkincluding the build and API reports, and the JS tests).Caused by
None. moq-dev/moq#8654 accepted the growth as feature growth, and this audit follows from it.
Decisions
Each was taken by the agent under /quest-afk, for the maintainer's review:
@moq/qmuxfromconnectWebSocket, after WebTransport's head start. Rejected: keeping it static (gives up 10.8 KB in every bundle). Rejected: moving the fallback behind a subpath export (breaking, and every browser consumer still needs the fallback). Rejected: prefetching it during the head start (the review's round 1 finding: every WebTransport session would still download it). The trade-off: a fallback dial waits for the chunk fetch once per page load.transport = "websocket"label onSession. Rejected: a WeakSet of sessions thatconnectdialed, which would misclassify a qmux session passed in throughtransportand change howopen.tsorders stream opens. Rejected:instanceofagainst the lazily loaded class, which fails before the module loads and across duplicate copies.pako. Rejected:CompressionStream, and fflate, alone or split with pako, for the reasons under Audit.@moq/hang's zod (9.1 KB). The catalog schemas are public, and watch needs them before playback, so a lazy catalog would be a breaking async API for one bundle-sized win. Rejected: filing it anyway.Alternatives
@moq/hang: breaking, and it would rebuild what zod/mini already does in about 9 KB.Follow-ups
None.
Models
Size: M
Implementer: Claude Code, claude-opus-5-5, unknown effort
Implementer: Claude Code, claude-opus-5-5, unknown effort (iterate, XS)
🤖 Generated with Claude Code
(Written by Claude Opus 5.5)
Review
mechanical fix by codex, gpt-6.1-sol, medium effort:
Merged base and completed the HopSchema.parse-to-toHop rename in six base-added tests; ran nix develop --command just ci base and git diff --check successfully.(head daf3f69)Rewritten by
quest reviewafter each round.Head: 6e61841
Base: origin/main at 99d789e
Reviewer: Codex, gpt-6.1-sol, high effort
Session:
01a1d8c2-d01a-7083-bd51-3b836913b359, round 3Score: 10/10,
The only incremental change accurately qualifies when lazy loading avoids downloading the fallback chunks, with no new issues found.Rounds: 3; findings 1 (medium 1): fixed 1, refuted 0, open 0
Findings: none