@torrent-tv/proxy 2.87.4 → 2.87.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +8 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +94 -94
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +212 -212
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +36 -36
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2 -2
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +1 -1
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/data-channel-handler.js +1356 -1356
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -73
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +176 -176
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -0
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/{services/torrent → utils}/torrent-source-key.js +38 -38
|
@@ -1,484 +1,484 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file WebRTC connection manager for the proxy (Node.js side).
|
|
3
|
-
*
|
|
4
|
-
* Maintains one `PeerConnection` per browser session, keyed by `sessionId`.
|
|
5
|
-
* Receives SDP offers and ICE candidates through the signalling tunnel
|
|
6
|
-
* and responds with SDP answers and its own candidates through the same channel.
|
|
7
|
-
*
|
|
8
|
-
* Data channels for actual streaming are handed off to `transport/data-channel-handler.js`
|
|
9
|
-
* via the `onDataChannel` callback — this module only owns the signalling phase.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import nodeDataChannel from "node-datachannel";
|
|
13
|
-
|
|
14
|
-
/** @import { WebRtcSignal } from './tunnel-client.js' */
|
|
15
|
-
|
|
16
|
-
// Two STUN servers from different operators, both with IPv6 (AAAA) records, so
|
|
17
|
-
// the proxy gathers a server-reflexive candidate over BOTH v4 and (when the
|
|
18
|
-
// host has global v6) v6 — enabling a direct IPv6 path on v6-native networks.
|
|
19
|
-
const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"];
|
|
20
|
-
|
|
21
|
-
// Symmetric-NAT port prediction window. For each real srflx candidate we offer
|
|
22
|
-
// this many extra candidates at ports base + delta*k (k = 1..N), because the
|
|
23
|
-
// number of NAT mappings the router allocates between STUN gathering and the
|
|
24
|
-
// browser connectivity check is unknown — a small window covers the likely
|
|
25
|
-
// values without bloating the SDP.
|
|
26
|
-
const PORT_PREDICTION_WINDOW = 16;
|
|
27
|
-
|
|
28
|
-
// Max size of a single data-channel message the proxy advertises (SDP
|
|
29
|
-
// `a=max-message-size`) and will accept. The browser caps `channel.send()` at
|
|
30
|
-
// the REMOTE's advertised value, so this is what lets the browser send a large
|
|
31
|
-
// request body in one message — notably registering a source, whose body is
|
|
32
|
-
// the base64-encoded .torrent. A big multi-season pack's .torrent (thousands of
|
|
33
|
-
// piece hashes) can be hundreds of KB (e.g. Poirot: 420 KB → ~560 KB base64),
|
|
34
|
-
// which exceeds libdatachannel's ~256 KB default and made `send()` throw
|
|
35
|
-
// "message larger than max-message-size". 16 MB is a generous ceiling (memory
|
|
36
|
-
// is allocated per actual message, not reserved). Responses (proxy→browser)
|
|
37
|
-
// are already safe — they stream in small reader-sized chunks.
|
|
38
|
-
const MAX_DC_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Build predicted srflx ICE candidates for a symmetric NAT.
|
|
42
|
-
*
|
|
43
|
-
* A symmetric NAT assigns a different external port per destination, so the
|
|
44
|
-
* STUN-learned srflx port is not the port the NAT will use toward the browser.
|
|
45
|
-
* Given the per-destination port `delta` measured at startup, we offer ports
|
|
46
|
-
* `base + delta*k` (k = 1..window) so the browser also probes them; if one
|
|
47
|
-
* matches the mapping the NAT creates for the proxy→browser path, ICE connects.
|
|
48
|
-
* This is the practical, signalling-only form of the birthday-paradox trick —
|
|
49
|
-
* it works for sequential/predictable symmetric NATs, not fully random ones.
|
|
50
|
-
*
|
|
51
|
-
* Each predicted candidate gets a unique foundation so ICE treats it as a
|
|
52
|
-
* distinct candidate. Returns [] for non-srflx, IPv6, missing/zero delta, or a
|
|
53
|
-
* candidate string we cannot parse.
|
|
54
|
-
*
|
|
55
|
-
* @param {string} candidate - Raw candidate string (with or without `a=`).
|
|
56
|
-
* @param {number} delta - Per-destination external-port delta (from NAT classification).
|
|
57
|
-
* @param {number} [windowSize]
|
|
58
|
-
* @returns {Array<{ candidate: string, port: number }>}
|
|
59
|
-
*/
|
|
60
|
-
function buildPredictedSrflxCandidates(candidate, delta, windowSize = PORT_PREDICTION_WINDOW) {
|
|
61
|
-
if (!Number.isInteger(delta) || delta === 0) {
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
const raw = candidate.replace(/^a=/, "");
|
|
65
|
-
if (!/ typ srflx /.test(raw)) {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
// candidate:<foundation> <component> <proto> <priority> <ip> <port> typ srflx ...
|
|
69
|
-
const parts = raw.split(" ");
|
|
70
|
-
if (parts.length < 8 || !parts[0].startsWith("candidate:")) {
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
const ip = parts[4];
|
|
74
|
-
if (typeof ip !== "string" || ip.includes(":")) {
|
|
75
|
-
// IPv6 has no NAT — port prediction is meaningless.
|
|
76
|
-
return [];
|
|
77
|
-
}
|
|
78
|
-
const basePort = Number(parts[5]);
|
|
79
|
-
if (!Number.isInteger(basePort)) {
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const out = [];
|
|
84
|
-
const seen = new Set([basePort]);
|
|
85
|
-
for (let k = 1; k <= windowSize; k++) {
|
|
86
|
-
const port = basePort + delta * k;
|
|
87
|
-
if (port < 1 || port > 65535 || seen.has(port)) {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
seen.add(port);
|
|
91
|
-
const p = parts.slice();
|
|
92
|
-
p[0] = `candidate:pp${k}`;
|
|
93
|
-
p[5] = String(port);
|
|
94
|
-
out.push({ candidate: p.join(" "), port });
|
|
95
|
-
}
|
|
96
|
-
return out;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Classify an ICE candidate by its address family and scope, for diagnostics.
|
|
101
|
-
*
|
|
102
|
-
* Returns one of: `v4-private`, `v4-public`, `v6-global`, `v6-ula`,
|
|
103
|
-
* `v6-linklocal`, `v6-loopback`, or `unknown`. This is logged for every
|
|
104
|
-
* candidate so the field log shows whether a **global IPv6** path is being
|
|
105
|
-
* offered (IPv6 has no NAT — if both sides have a global v6 address the
|
|
106
|
-
* connection can go direct, which matters for v6-native mobile networks).
|
|
107
|
-
*
|
|
108
|
-
* Note on PNA: a `v4-private` host candidate triggers the browser's Private
|
|
109
|
-
* Network Access permission prompt; the connection otherwise proceeds via the
|
|
110
|
-
* public srflx candidate. We forward all candidates regardless (the browser
|
|
111
|
-
* decides) — this only labels them.
|
|
112
|
-
*
|
|
113
|
-
* @param {string} candidate - Raw candidate attribute string (with or without `a=`).
|
|
114
|
-
* @returns {"v4-private"|"v4-public"|"v6-global"|"v6-ula"|"v6-linklocal"|"v6-loopback"|"unknown"}
|
|
115
|
-
*/
|
|
116
|
-
function candidateAddrKind(candidate) {
|
|
117
|
-
const parts = candidate.replace(/^a=/, "").split(" ");
|
|
118
|
-
const ip = parts.length > 4 ? parts[4] : "";
|
|
119
|
-
if (!ip) {
|
|
120
|
-
return "unknown";
|
|
121
|
-
}
|
|
122
|
-
if (!ip.includes(":")) {
|
|
123
|
-
// IPv4: RFC 1918 / loopback / link-local are private.
|
|
124
|
-
if (/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) {
|
|
125
|
-
return "v4-private";
|
|
126
|
-
}
|
|
127
|
-
return "v4-public";
|
|
128
|
-
}
|
|
129
|
-
const low = ip.toLowerCase();
|
|
130
|
-
if (low === "::1") {
|
|
131
|
-
return "v6-loopback";
|
|
132
|
-
}
|
|
133
|
-
if (low.startsWith("fe80")) {
|
|
134
|
-
return "v6-linklocal";
|
|
135
|
-
}
|
|
136
|
-
if (/^f[cd][0-9a-f]{2}:/.test(low)) {
|
|
137
|
-
// Unique Local Address (fc00::/7).
|
|
138
|
-
return "v6-ula";
|
|
139
|
-
}
|
|
140
|
-
return "v6-global";
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Configuration for the WebRTC manager.
|
|
145
|
-
*
|
|
146
|
-
* @typedef {Object} WebRtcManagerOptions
|
|
147
|
-
* @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
|
|
148
|
-
* Send a WebRTC signal (answer or ICE candidate) back to the browser via
|
|
149
|
-
* the tunnel. Typically wired to `tunnelClient.sendSignal`.
|
|
150
|
-
* @property {(sessionId: string, channel: import("node-datachannel").DataChannel) => void} onDataChannel
|
|
151
|
-
* Called when the browser opens a data channel. Receives the raw
|
|
152
|
-
* `node-datachannel` `DataChannel` object; hand it to `createDataChannelHandler`.
|
|
153
|
-
* @property {(message: string) => void} [onLog]
|
|
154
|
-
* Optional log sink.
|
|
155
|
-
* @property {() => ({ klass: string, portDelta: number|null } | null)} [getNatInfo]
|
|
156
|
-
* Returns the latest NAT classification (or null if not yet known). When it
|
|
157
|
-
* reports a symmetric NAT with a known port delta, each srflx candidate is
|
|
158
|
-
* accompanied by predicted-port candidates (see {@link buildPredictedSrflxCandidates}).
|
|
159
|
-
* @property {number} [udpPort]
|
|
160
|
-
* When set, all WebRTC sessions are multiplexed onto this single UDP port so
|
|
161
|
-
* it can be statically UPnP-mapped (one mapping, one reachable port). A
|
|
162
|
-
* persistent {@link import("node-datachannel").IceUdpMuxListener} is created
|
|
163
|
-
* once at startup and owns the shared socket; every PeerConnection enables
|
|
164
|
-
* `enableIceUdpMux` on the same port and demuxes over it by ICE ufrag.
|
|
165
|
-
*
|
|
166
|
-
* The persistent listener is the crucial part: per-PeerConnection
|
|
167
|
-
* `enableIceUdpMux` WITHOUT it ties the shared socket to a connection's
|
|
168
|
-
* lifetime, so a freshly-opened session fails to bind the port while a
|
|
169
|
-
* just-closed one still holds it → "Failed to gather local ICE candidates"
|
|
170
|
-
* (which crashed the proxy). The listener keeps the socket alive across
|
|
171
|
-
* sessions; verified with sequential + concurrent connections on one port.
|
|
172
|
-
* When omitted, node-datachannel uses an ephemeral UDP port.
|
|
173
|
-
*/
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The object returned by {@link createWebRtcManager}.
|
|
177
|
-
*
|
|
178
|
-
* @typedef {Object} WebRtcManager
|
|
179
|
-
* @property {(sessionId: string, signal: WebRtcSignal) => void} handleSignal
|
|
180
|
-
* Dispatch an incoming signal (offer / ICE candidate) from the browser to
|
|
181
|
-
* the matching peer connection, creating it if necessary.
|
|
182
|
-
* @property {(sessionId: string) => void} closeSession
|
|
183
|
-
* Tear down and remove a peer connection by session ID.
|
|
184
|
-
* @property {() => void} dispose
|
|
185
|
-
* Close all peer connections and stop the shared UDP mux listener. Call on
|
|
186
|
-
* proxy shutdown so the listener's socket is released.
|
|
187
|
-
*/
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Create the proxy-side WebRTC connection manager.
|
|
191
|
-
*
|
|
192
|
-
* @param {WebRtcManagerOptions} options
|
|
193
|
-
* @returns {WebRtcManager}
|
|
194
|
-
*/
|
|
195
|
-
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
|
|
196
|
-
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
197
|
-
const peers = new Map();
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* @param {string} message
|
|
201
|
-
* @returns {void}
|
|
202
|
-
*/
|
|
203
|
-
function log(message) {
|
|
204
|
-
if (typeof onLog === "function") {
|
|
205
|
-
onLog(message);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Base PeerConnection config shared by every session.
|
|
210
|
-
const pcConfig = { iceServers: ICE_SERVERS, maxMessageSize: MAX_DC_MESSAGE_BYTES };
|
|
211
|
-
|
|
212
|
-
// Single-port UDP mux: create ONE persistent listener that owns the shared
|
|
213
|
-
// UDP socket for the proxy's whole lifetime, then have every PeerConnection
|
|
214
|
-
// mux over it (enableIceUdpMux + the same fixed port). The listener must
|
|
215
|
-
// outlive individual sessions — without it the socket is bound/freed per
|
|
216
|
-
// connection and a repeat session fails to gather ICE candidates.
|
|
217
|
-
/** @type {import("node-datachannel").IceUdpMuxListener | null} */
|
|
218
|
-
let udpMuxListener = null;
|
|
219
|
-
if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
|
|
220
|
-
try {
|
|
221
|
-
udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
|
|
222
|
-
// STUN that doesn't match an existing session (our PeerConnections are
|
|
223
|
-
// created from the SDP offer before the browser's STUN arrives, so normal
|
|
224
|
-
// traffic is "handled"). Stray/unhandled requests are simply dropped.
|
|
225
|
-
udpMuxListener.onUnhandledStunRequest(() => {});
|
|
226
|
-
pcConfig.enableIceUdpMux = true;
|
|
227
|
-
pcConfig.portRangeBegin = udpPort;
|
|
228
|
-
pcConfig.portRangeEnd = udpPort;
|
|
229
|
-
log(`[webrtc] UDP mux listener bound on port ${udpPort}; all sessions share it`);
|
|
230
|
-
} catch (error) {
|
|
231
|
-
// Could not bind the mux port — fall back to ephemeral UDP ports (no
|
|
232
|
-
// single-port reachability, but the proxy still works on LAN / via STUN).
|
|
233
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
234
|
-
log(`[webrtc] UDP mux listener failed on port ${udpPort} (${message}); using ephemeral ports`);
|
|
235
|
-
udpMuxListener = null;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Retrieve an existing peer connection or create a new one for the session.
|
|
241
|
-
*
|
|
242
|
-
* A new `PeerConnection` is configured with the shared ICE server list and
|
|
243
|
-
* wired up to forward local descriptions and candidates back to the browser.
|
|
244
|
-
*
|
|
245
|
-
* @param {string} sessionId
|
|
246
|
-
* @returns {import("node-datachannel").PeerConnection}
|
|
247
|
-
*/
|
|
248
|
-
function getOrCreatePeer(sessionId) {
|
|
249
|
-
const existing = peers.get(sessionId);
|
|
250
|
-
if (existing) {
|
|
251
|
-
return existing;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`, pcConfig);
|
|
255
|
-
|
|
256
|
-
// Forward all ICE candidates to the browser through the tunnel.
|
|
257
|
-
//
|
|
258
|
-
// All candidates — both private (RFC 1918) and public (srflx) — are sent
|
|
259
|
-
// immediately. The browser will attempt all paths in parallel and use
|
|
260
|
-
// whichever connects first. Private candidates trigger Chrome's Private
|
|
261
|
-
// Network Access (PNA) permission dialog once; after the user allows it
|
|
262
|
-
// the connection proceeds via the local LAN path.
|
|
263
|
-
pc.onLocalCandidate((candidate, mid) => {
|
|
264
|
-
const kind = candidateAddrKind(candidate);
|
|
265
|
-
// Log the full candidate (addr:port typ …) with its address kind so we
|
|
266
|
-
// can confirm WebRTC is pinned to the mapped UDP port, see whether a
|
|
267
|
-
// global IPv6 path is offered, and diagnose which routes are available.
|
|
268
|
-
log(
|
|
269
|
-
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
|
|
270
|
-
);
|
|
271
|
-
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
272
|
-
|
|
273
|
-
// Symmetric-NAT port prediction: offer extra srflx candidates at the
|
|
274
|
-
// predicted external ports so the browser probes them too. No-op unless
|
|
275
|
-
// the NAT is symmetric with a known delta and this is an IPv4 srflx
|
|
276
|
-
// candidate. Best-effort — never let it break candidate forwarding.
|
|
277
|
-
try {
|
|
278
|
-
const nat = typeof getNatInfo === "function" ? getNatInfo() : null;
|
|
279
|
-
if (nat && nat.klass === "symmetric") {
|
|
280
|
-
const predicted = buildPredictedSrflxCandidates(candidate, nat.portDelta);
|
|
281
|
-
if (predicted.length > 0) {
|
|
282
|
-
log(
|
|
283
|
-
`[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
|
|
284
|
-
);
|
|
285
|
-
for (const p of predicted) {
|
|
286
|
-
sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
} catch (error) {
|
|
291
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: port-prediction inject failed: ${message}`);
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
pc.onGatheringStateChange((state) => {
|
|
297
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: gathering → ${state}`);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// Forward our SDP answer to the browser through the tunnel.
|
|
301
|
-
pc.onLocalDescription((sdp, type) => {
|
|
302
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${type}`);
|
|
303
|
-
sendSignal(sessionId, { type, sdp });
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
pc.onStateChange((state) => {
|
|
307
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: state → ${state}`);
|
|
308
|
-
// On connect, log which candidate pair actually won — this is the single
|
|
309
|
-
// most useful line for "did the open-port/WebRTC path work, and over
|
|
310
|
-
// which route (LAN / public srflx v4 / v6)".
|
|
311
|
-
if (state === "connected") {
|
|
312
|
-
try {
|
|
313
|
-
const pair = pc.getSelectedCandidatePair();
|
|
314
|
-
if (pair) {
|
|
315
|
-
const fmt = (c) => `${c.type} ${c.address}:${c.port}/${c.transportType}`;
|
|
316
|
-
log(
|
|
317
|
-
`[webrtc] Session ${sessionId.slice(0, 8)}: selected pair local=[${fmt(pair.local)}] remote=[${fmt(pair.remote)}]`
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
} catch {
|
|
321
|
-
// Diagnostics only — never let a logging call affect the connection.
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
// "disconnected" is a transient state — ICE may recover on its own.
|
|
325
|
-
// Only tear down on terminal states: "failed" and "closed".
|
|
326
|
-
if (state === "failed" || state === "closed") {
|
|
327
|
-
closeSession(sessionId);
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
// Granular ICE-level transitions (checking → connected/failed) — finer than
|
|
332
|
-
// the peer state above; pinpoints where a failing connection stalls.
|
|
333
|
-
pc.onIceStateChange((state) => {
|
|
334
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: ICE → ${state}`);
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
// Browser creates the data channel — we receive it here.
|
|
338
|
-
pc.onDataChannel((channel) => {
|
|
339
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
|
|
340
|
-
onDataChannel(sessionId, channel);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
peers.set(sessionId, pc);
|
|
344
|
-
return pc;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Dispatch an incoming WebRTC signal from the browser.
|
|
349
|
-
*
|
|
350
|
-
* - `offer`: creates (or reuses) the peer connection and sets the remote
|
|
351
|
-
* description; the library fires `onLocalDescription` automatically with
|
|
352
|
-
* the SDP answer.
|
|
353
|
-
* - `candidate`: adds the ICE candidate to the existing peer connection.
|
|
354
|
-
*
|
|
355
|
-
* @param {string} sessionId
|
|
356
|
-
* @param {WebRtcSignal} signal
|
|
357
|
-
* @returns {void}
|
|
358
|
-
*/
|
|
359
|
-
function handleSignal(sessionId, signal) {
|
|
360
|
-
if (!signal || typeof signal.type !== "string") {
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (signal.type === "offer") {
|
|
365
|
-
if (typeof signal.sdp !== "string") {
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: received offer`);
|
|
369
|
-
// node-datachannel can throw SYNCHRONOUSLY here (e.g. "Failed to gather
|
|
370
|
-
// local ICE candidates" when the pinned UDP port cannot be bound). A
|
|
371
|
-
// single bad session must never crash the whole proxy — that would drop
|
|
372
|
-
// every other viewer and the tunnel. Contain it: fail this session only.
|
|
373
|
-
try {
|
|
374
|
-
const pc = getOrCreatePeer(sessionId);
|
|
375
|
-
pc.setRemoteDescription(signal.sdp, "offer");
|
|
376
|
-
// `onLocalDescription` fires automatically with the SDP answer.
|
|
377
|
-
} catch (error) {
|
|
378
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
|
|
380
|
-
closeSession(sessionId);
|
|
381
|
-
}
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (signal.type === "candidate") {
|
|
386
|
-
const pc = peers.get(sessionId);
|
|
387
|
-
if (!pc) {
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (typeof signal.candidate === "string" && typeof signal.mid === "string") {
|
|
391
|
-
try {
|
|
392
|
-
pc.addRemoteCandidate(signal.candidate, signal.mid);
|
|
393
|
-
} catch (error) {
|
|
394
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
395
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to add candidate: ${message}`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Close and remove the peer connection for a session.
|
|
403
|
-
* Called automatically when the connection enters a terminal state.
|
|
404
|
-
*
|
|
405
|
-
* @param {string} sessionId
|
|
406
|
-
* @returns {void}
|
|
407
|
-
*/
|
|
408
|
-
function closeSession(sessionId) {
|
|
409
|
-
const pc = peers.get(sessionId);
|
|
410
|
-
if (pc) {
|
|
411
|
-
try { pc.close(); } catch { /* ignore */ }
|
|
412
|
-
peers.delete(sessionId);
|
|
413
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: closed`);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Close all peer connections and stop the shared UDP mux listener.
|
|
419
|
-
*
|
|
420
|
-
* @returns {void}
|
|
421
|
-
*/
|
|
422
|
-
function dispose() {
|
|
423
|
-
for (const sessionId of [...peers.keys()]) {
|
|
424
|
-
closeSession(sessionId);
|
|
425
|
-
}
|
|
426
|
-
if (udpMuxListener) {
|
|
427
|
-
try { udpMuxListener.stop(); } catch { /* ignore */ }
|
|
428
|
-
udpMuxListener = null;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* What the transport itself is doing for one session.
|
|
434
|
-
*
|
|
435
|
-
* The discriminator for a send queue that stops draining. `bufferedAmount`
|
|
436
|
-
* alone says only that bytes are stuck in OUR queue; these say whether they
|
|
437
|
-
* are leaving the machine:
|
|
438
|
-
*
|
|
439
|
-
* - `bytesSent` climbing while the queue climbs → packets ARE going out and
|
|
440
|
-
* nothing is acknowledging them: the path back is broken.
|
|
441
|
-
* - `bytesSent` flat while the queue climbs → SCTP is not transmitting at
|
|
442
|
-
* all: the peer's receive window is closed, or congestion control has
|
|
443
|
-
* collapsed. `bytesReceived` still climbing at the same time proves the
|
|
444
|
-
* peer is alive and its packets still reach us, i.e. the failure is
|
|
445
|
-
* one-directional.
|
|
446
|
-
* - both flat → nothing crosses in either direction.
|
|
447
|
-
*
|
|
448
|
-
* @param {string} sessionId
|
|
449
|
-
* @returns {{ bytesSent: number, bytesReceived: number, rtt: number, pair: string, remote: { address: string, port: number } | null, state: string, iceState: string } | null}
|
|
450
|
-
*/
|
|
451
|
-
function getTransportSnapshot(sessionId) {
|
|
452
|
-
const pc = peers.get(sessionId);
|
|
453
|
-
if (!pc) {
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
const read = (fn, fallback) => {
|
|
457
|
-
try {
|
|
458
|
-
return fn();
|
|
459
|
-
} catch {
|
|
460
|
-
return fallback;
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
const pair = read(() => pc.getSelectedCandidatePair(), null);
|
|
464
|
-
return {
|
|
465
|
-
bytesSent: read(() => pc.bytesSent(), -1),
|
|
466
|
-
bytesReceived: read(() => pc.bytesReceived(), -1),
|
|
467
|
-
rtt: read(() => pc.rtt(), -1),
|
|
468
|
-
pair: pair
|
|
469
|
-
? `${pair.local?.address}:${pair.local?.port}->${pair.remote?.address}:${pair.remote?.port}` +
|
|
470
|
-
` (${pair.local?.type}/${pair.remote?.type})`
|
|
471
|
-
: "none",
|
|
472
|
-
// Structured, for consumers that need to act on the endpoint itself (the
|
|
473
|
-
// packet witness builds a tcpdump filter from it) rather than print it.
|
|
474
|
-
remote:
|
|
475
|
-
typeof pair?.remote?.address === "string" && Number.isFinite(pair?.remote?.port)
|
|
476
|
-
? { address: pair.remote.address, port: pair.remote.port }
|
|
477
|
-
: null,
|
|
478
|
-
state: read(() => pc.state(), "?"),
|
|
479
|
-
iceState: read(() => pc.iceState(), "?")
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return { handleSignal, closeSession, dispose, getTransportSnapshot };
|
|
484
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file WebRTC connection manager for the proxy (Node.js side).
|
|
3
|
+
*
|
|
4
|
+
* Maintains one `PeerConnection` per browser session, keyed by `sessionId`.
|
|
5
|
+
* Receives SDP offers and ICE candidates through the signalling tunnel
|
|
6
|
+
* and responds with SDP answers and its own candidates through the same channel.
|
|
7
|
+
*
|
|
8
|
+
* Data channels for actual streaming are handed off to `transport/data-channel-handler.js`
|
|
9
|
+
* via the `onDataChannel` callback — this module only owns the signalling phase.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import nodeDataChannel from "node-datachannel";
|
|
13
|
+
|
|
14
|
+
/** @import { WebRtcSignal } from './tunnel-client.js' */
|
|
15
|
+
|
|
16
|
+
// Two STUN servers from different operators, both with IPv6 (AAAA) records, so
|
|
17
|
+
// the proxy gathers a server-reflexive candidate over BOTH v4 and (when the
|
|
18
|
+
// host has global v6) v6 — enabling a direct IPv6 path on v6-native networks.
|
|
19
|
+
const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"];
|
|
20
|
+
|
|
21
|
+
// Symmetric-NAT port prediction window. For each real srflx candidate we offer
|
|
22
|
+
// this many extra candidates at ports base + delta*k (k = 1..N), because the
|
|
23
|
+
// number of NAT mappings the router allocates between STUN gathering and the
|
|
24
|
+
// browser connectivity check is unknown — a small window covers the likely
|
|
25
|
+
// values without bloating the SDP.
|
|
26
|
+
const PORT_PREDICTION_WINDOW = 16;
|
|
27
|
+
|
|
28
|
+
// Max size of a single data-channel message the proxy advertises (SDP
|
|
29
|
+
// `a=max-message-size`) and will accept. The browser caps `channel.send()` at
|
|
30
|
+
// the REMOTE's advertised value, so this is what lets the browser send a large
|
|
31
|
+
// request body in one message — notably registering a source, whose body is
|
|
32
|
+
// the base64-encoded .torrent. A big multi-season pack's .torrent (thousands of
|
|
33
|
+
// piece hashes) can be hundreds of KB (e.g. Poirot: 420 KB → ~560 KB base64),
|
|
34
|
+
// which exceeds libdatachannel's ~256 KB default and made `send()` throw
|
|
35
|
+
// "message larger than max-message-size". 16 MB is a generous ceiling (memory
|
|
36
|
+
// is allocated per actual message, not reserved). Responses (proxy→browser)
|
|
37
|
+
// are already safe — they stream in small reader-sized chunks.
|
|
38
|
+
const MAX_DC_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build predicted srflx ICE candidates for a symmetric NAT.
|
|
42
|
+
*
|
|
43
|
+
* A symmetric NAT assigns a different external port per destination, so the
|
|
44
|
+
* STUN-learned srflx port is not the port the NAT will use toward the browser.
|
|
45
|
+
* Given the per-destination port `delta` measured at startup, we offer ports
|
|
46
|
+
* `base + delta*k` (k = 1..window) so the browser also probes them; if one
|
|
47
|
+
* matches the mapping the NAT creates for the proxy→browser path, ICE connects.
|
|
48
|
+
* This is the practical, signalling-only form of the birthday-paradox trick —
|
|
49
|
+
* it works for sequential/predictable symmetric NATs, not fully random ones.
|
|
50
|
+
*
|
|
51
|
+
* Each predicted candidate gets a unique foundation so ICE treats it as a
|
|
52
|
+
* distinct candidate. Returns [] for non-srflx, IPv6, missing/zero delta, or a
|
|
53
|
+
* candidate string we cannot parse.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} candidate - Raw candidate string (with or without `a=`).
|
|
56
|
+
* @param {number} delta - Per-destination external-port delta (from NAT classification).
|
|
57
|
+
* @param {number} [windowSize]
|
|
58
|
+
* @returns {Array<{ candidate: string, port: number }>}
|
|
59
|
+
*/
|
|
60
|
+
function buildPredictedSrflxCandidates(candidate, delta, windowSize = PORT_PREDICTION_WINDOW) {
|
|
61
|
+
if (!Number.isInteger(delta) || delta === 0) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const raw = candidate.replace(/^a=/, "");
|
|
65
|
+
if (!/ typ srflx /.test(raw)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
// candidate:<foundation> <component> <proto> <priority> <ip> <port> typ srflx ...
|
|
69
|
+
const parts = raw.split(" ");
|
|
70
|
+
if (parts.length < 8 || !parts[0].startsWith("candidate:")) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const ip = parts[4];
|
|
74
|
+
if (typeof ip !== "string" || ip.includes(":")) {
|
|
75
|
+
// IPv6 has no NAT — port prediction is meaningless.
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const basePort = Number(parts[5]);
|
|
79
|
+
if (!Number.isInteger(basePort)) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const out = [];
|
|
84
|
+
const seen = new Set([basePort]);
|
|
85
|
+
for (let k = 1; k <= windowSize; k++) {
|
|
86
|
+
const port = basePort + delta * k;
|
|
87
|
+
if (port < 1 || port > 65535 || seen.has(port)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
seen.add(port);
|
|
91
|
+
const p = parts.slice();
|
|
92
|
+
p[0] = `candidate:pp${k}`;
|
|
93
|
+
p[5] = String(port);
|
|
94
|
+
out.push({ candidate: p.join(" "), port });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Classify an ICE candidate by its address family and scope, for diagnostics.
|
|
101
|
+
*
|
|
102
|
+
* Returns one of: `v4-private`, `v4-public`, `v6-global`, `v6-ula`,
|
|
103
|
+
* `v6-linklocal`, `v6-loopback`, or `unknown`. This is logged for every
|
|
104
|
+
* candidate so the field log shows whether a **global IPv6** path is being
|
|
105
|
+
* offered (IPv6 has no NAT — if both sides have a global v6 address the
|
|
106
|
+
* connection can go direct, which matters for v6-native mobile networks).
|
|
107
|
+
*
|
|
108
|
+
* Note on PNA: a `v4-private` host candidate triggers the browser's Private
|
|
109
|
+
* Network Access permission prompt; the connection otherwise proceeds via the
|
|
110
|
+
* public srflx candidate. We forward all candidates regardless (the browser
|
|
111
|
+
* decides) — this only labels them.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} candidate - Raw candidate attribute string (with or without `a=`).
|
|
114
|
+
* @returns {"v4-private"|"v4-public"|"v6-global"|"v6-ula"|"v6-linklocal"|"v6-loopback"|"unknown"}
|
|
115
|
+
*/
|
|
116
|
+
function candidateAddrKind(candidate) {
|
|
117
|
+
const parts = candidate.replace(/^a=/, "").split(" ");
|
|
118
|
+
const ip = parts.length > 4 ? parts[4] : "";
|
|
119
|
+
if (!ip) {
|
|
120
|
+
return "unknown";
|
|
121
|
+
}
|
|
122
|
+
if (!ip.includes(":")) {
|
|
123
|
+
// IPv4: RFC 1918 / loopback / link-local are private.
|
|
124
|
+
if (/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) {
|
|
125
|
+
return "v4-private";
|
|
126
|
+
}
|
|
127
|
+
return "v4-public";
|
|
128
|
+
}
|
|
129
|
+
const low = ip.toLowerCase();
|
|
130
|
+
if (low === "::1") {
|
|
131
|
+
return "v6-loopback";
|
|
132
|
+
}
|
|
133
|
+
if (low.startsWith("fe80")) {
|
|
134
|
+
return "v6-linklocal";
|
|
135
|
+
}
|
|
136
|
+
if (/^f[cd][0-9a-f]{2}:/.test(low)) {
|
|
137
|
+
// Unique Local Address (fc00::/7).
|
|
138
|
+
return "v6-ula";
|
|
139
|
+
}
|
|
140
|
+
return "v6-global";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Configuration for the WebRTC manager.
|
|
145
|
+
*
|
|
146
|
+
* @typedef {Object} WebRtcManagerOptions
|
|
147
|
+
* @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
|
|
148
|
+
* Send a WebRTC signal (answer or ICE candidate) back to the browser via
|
|
149
|
+
* the tunnel. Typically wired to `tunnelClient.sendSignal`.
|
|
150
|
+
* @property {(sessionId: string, channel: import("node-datachannel").DataChannel) => void} onDataChannel
|
|
151
|
+
* Called when the browser opens a data channel. Receives the raw
|
|
152
|
+
* `node-datachannel` `DataChannel` object; hand it to `createDataChannelHandler`.
|
|
153
|
+
* @property {(message: string) => void} [onLog]
|
|
154
|
+
* Optional log sink.
|
|
155
|
+
* @property {() => ({ klass: string, portDelta: number|null } | null)} [getNatInfo]
|
|
156
|
+
* Returns the latest NAT classification (or null if not yet known). When it
|
|
157
|
+
* reports a symmetric NAT with a known port delta, each srflx candidate is
|
|
158
|
+
* accompanied by predicted-port candidates (see {@link buildPredictedSrflxCandidates}).
|
|
159
|
+
* @property {number} [udpPort]
|
|
160
|
+
* When set, all WebRTC sessions are multiplexed onto this single UDP port so
|
|
161
|
+
* it can be statically UPnP-mapped (one mapping, one reachable port). A
|
|
162
|
+
* persistent {@link import("node-datachannel").IceUdpMuxListener} is created
|
|
163
|
+
* once at startup and owns the shared socket; every PeerConnection enables
|
|
164
|
+
* `enableIceUdpMux` on the same port and demuxes over it by ICE ufrag.
|
|
165
|
+
*
|
|
166
|
+
* The persistent listener is the crucial part: per-PeerConnection
|
|
167
|
+
* `enableIceUdpMux` WITHOUT it ties the shared socket to a connection's
|
|
168
|
+
* lifetime, so a freshly-opened session fails to bind the port while a
|
|
169
|
+
* just-closed one still holds it → "Failed to gather local ICE candidates"
|
|
170
|
+
* (which crashed the proxy). The listener keeps the socket alive across
|
|
171
|
+
* sessions; verified with sequential + concurrent connections on one port.
|
|
172
|
+
* When omitted, node-datachannel uses an ephemeral UDP port.
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The object returned by {@link createWebRtcManager}.
|
|
177
|
+
*
|
|
178
|
+
* @typedef {Object} WebRtcManager
|
|
179
|
+
* @property {(sessionId: string, signal: WebRtcSignal) => void} handleSignal
|
|
180
|
+
* Dispatch an incoming signal (offer / ICE candidate) from the browser to
|
|
181
|
+
* the matching peer connection, creating it if necessary.
|
|
182
|
+
* @property {(sessionId: string) => void} closeSession
|
|
183
|
+
* Tear down and remove a peer connection by session ID.
|
|
184
|
+
* @property {() => void} dispose
|
|
185
|
+
* Close all peer connections and stop the shared UDP mux listener. Call on
|
|
186
|
+
* proxy shutdown so the listener's socket is released.
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create the proxy-side WebRTC connection manager.
|
|
191
|
+
*
|
|
192
|
+
* @param {WebRtcManagerOptions} options
|
|
193
|
+
* @returns {WebRtcManager}
|
|
194
|
+
*/
|
|
195
|
+
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
|
|
196
|
+
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
197
|
+
const peers = new Map();
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} message
|
|
201
|
+
* @returns {void}
|
|
202
|
+
*/
|
|
203
|
+
function log(message) {
|
|
204
|
+
if (typeof onLog === "function") {
|
|
205
|
+
onLog(message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Base PeerConnection config shared by every session.
|
|
210
|
+
const pcConfig = { iceServers: ICE_SERVERS, maxMessageSize: MAX_DC_MESSAGE_BYTES };
|
|
211
|
+
|
|
212
|
+
// Single-port UDP mux: create ONE persistent listener that owns the shared
|
|
213
|
+
// UDP socket for the proxy's whole lifetime, then have every PeerConnection
|
|
214
|
+
// mux over it (enableIceUdpMux + the same fixed port). The listener must
|
|
215
|
+
// outlive individual sessions — without it the socket is bound/freed per
|
|
216
|
+
// connection and a repeat session fails to gather ICE candidates.
|
|
217
|
+
/** @type {import("node-datachannel").IceUdpMuxListener | null} */
|
|
218
|
+
let udpMuxListener = null;
|
|
219
|
+
if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
|
|
220
|
+
try {
|
|
221
|
+
udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
|
|
222
|
+
// STUN that doesn't match an existing session (our PeerConnections are
|
|
223
|
+
// created from the SDP offer before the browser's STUN arrives, so normal
|
|
224
|
+
// traffic is "handled"). Stray/unhandled requests are simply dropped.
|
|
225
|
+
udpMuxListener.onUnhandledStunRequest(() => {});
|
|
226
|
+
pcConfig.enableIceUdpMux = true;
|
|
227
|
+
pcConfig.portRangeBegin = udpPort;
|
|
228
|
+
pcConfig.portRangeEnd = udpPort;
|
|
229
|
+
log(`[webrtc] UDP mux listener bound on port ${udpPort}; all sessions share it`);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Could not bind the mux port — fall back to ephemeral UDP ports (no
|
|
232
|
+
// single-port reachability, but the proxy still works on LAN / via STUN).
|
|
233
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
234
|
+
log(`[webrtc] UDP mux listener failed on port ${udpPort} (${message}); using ephemeral ports`);
|
|
235
|
+
udpMuxListener = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Retrieve an existing peer connection or create a new one for the session.
|
|
241
|
+
*
|
|
242
|
+
* A new `PeerConnection` is configured with the shared ICE server list and
|
|
243
|
+
* wired up to forward local descriptions and candidates back to the browser.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} sessionId
|
|
246
|
+
* @returns {import("node-datachannel").PeerConnection}
|
|
247
|
+
*/
|
|
248
|
+
function getOrCreatePeer(sessionId) {
|
|
249
|
+
const existing = peers.get(sessionId);
|
|
250
|
+
if (existing) {
|
|
251
|
+
return existing;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`, pcConfig);
|
|
255
|
+
|
|
256
|
+
// Forward all ICE candidates to the browser through the tunnel.
|
|
257
|
+
//
|
|
258
|
+
// All candidates — both private (RFC 1918) and public (srflx) — are sent
|
|
259
|
+
// immediately. The browser will attempt all paths in parallel and use
|
|
260
|
+
// whichever connects first. Private candidates trigger Chrome's Private
|
|
261
|
+
// Network Access (PNA) permission dialog once; after the user allows it
|
|
262
|
+
// the connection proceeds via the local LAN path.
|
|
263
|
+
pc.onLocalCandidate((candidate, mid) => {
|
|
264
|
+
const kind = candidateAddrKind(candidate);
|
|
265
|
+
// Log the full candidate (addr:port typ …) with its address kind so we
|
|
266
|
+
// can confirm WebRTC is pinned to the mapped UDP port, see whether a
|
|
267
|
+
// global IPv6 path is offered, and diagnose which routes are available.
|
|
268
|
+
log(
|
|
269
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
|
|
270
|
+
);
|
|
271
|
+
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
272
|
+
|
|
273
|
+
// Symmetric-NAT port prediction: offer extra srflx candidates at the
|
|
274
|
+
// predicted external ports so the browser probes them too. No-op unless
|
|
275
|
+
// the NAT is symmetric with a known delta and this is an IPv4 srflx
|
|
276
|
+
// candidate. Best-effort — never let it break candidate forwarding.
|
|
277
|
+
try {
|
|
278
|
+
const nat = typeof getNatInfo === "function" ? getNatInfo() : null;
|
|
279
|
+
if (nat && nat.klass === "symmetric") {
|
|
280
|
+
const predicted = buildPredictedSrflxCandidates(candidate, nat.portDelta);
|
|
281
|
+
if (predicted.length > 0) {
|
|
282
|
+
log(
|
|
283
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
|
|
284
|
+
);
|
|
285
|
+
for (const p of predicted) {
|
|
286
|
+
sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: port-prediction inject failed: ${message}`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
pc.onGatheringStateChange((state) => {
|
|
297
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: gathering → ${state}`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Forward our SDP answer to the browser through the tunnel.
|
|
301
|
+
pc.onLocalDescription((sdp, type) => {
|
|
302
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${type}`);
|
|
303
|
+
sendSignal(sessionId, { type, sdp });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
pc.onStateChange((state) => {
|
|
307
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: state → ${state}`);
|
|
308
|
+
// On connect, log which candidate pair actually won — this is the single
|
|
309
|
+
// most useful line for "did the open-port/WebRTC path work, and over
|
|
310
|
+
// which route (LAN / public srflx v4 / v6)".
|
|
311
|
+
if (state === "connected") {
|
|
312
|
+
try {
|
|
313
|
+
const pair = pc.getSelectedCandidatePair();
|
|
314
|
+
if (pair) {
|
|
315
|
+
const fmt = (c) => `${c.type} ${c.address}:${c.port}/${c.transportType}`;
|
|
316
|
+
log(
|
|
317
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: selected pair local=[${fmt(pair.local)}] remote=[${fmt(pair.remote)}]`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Diagnostics only — never let a logging call affect the connection.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// "disconnected" is a transient state — ICE may recover on its own.
|
|
325
|
+
// Only tear down on terminal states: "failed" and "closed".
|
|
326
|
+
if (state === "failed" || state === "closed") {
|
|
327
|
+
closeSession(sessionId);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Granular ICE-level transitions (checking → connected/failed) — finer than
|
|
332
|
+
// the peer state above; pinpoints where a failing connection stalls.
|
|
333
|
+
pc.onIceStateChange((state) => {
|
|
334
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: ICE → ${state}`);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Browser creates the data channel — we receive it here.
|
|
338
|
+
pc.onDataChannel((channel) => {
|
|
339
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
|
|
340
|
+
onDataChannel(sessionId, channel);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
peers.set(sessionId, pc);
|
|
344
|
+
return pc;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Dispatch an incoming WebRTC signal from the browser.
|
|
349
|
+
*
|
|
350
|
+
* - `offer`: creates (or reuses) the peer connection and sets the remote
|
|
351
|
+
* description; the library fires `onLocalDescription` automatically with
|
|
352
|
+
* the SDP answer.
|
|
353
|
+
* - `candidate`: adds the ICE candidate to the existing peer connection.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} sessionId
|
|
356
|
+
* @param {WebRtcSignal} signal
|
|
357
|
+
* @returns {void}
|
|
358
|
+
*/
|
|
359
|
+
function handleSignal(sessionId, signal) {
|
|
360
|
+
if (!signal || typeof signal.type !== "string") {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (signal.type === "offer") {
|
|
365
|
+
if (typeof signal.sdp !== "string") {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: received offer`);
|
|
369
|
+
// node-datachannel can throw SYNCHRONOUSLY here (e.g. "Failed to gather
|
|
370
|
+
// local ICE candidates" when the pinned UDP port cannot be bound). A
|
|
371
|
+
// single bad session must never crash the whole proxy — that would drop
|
|
372
|
+
// every other viewer and the tunnel. Contain it: fail this session only.
|
|
373
|
+
try {
|
|
374
|
+
const pc = getOrCreatePeer(sessionId);
|
|
375
|
+
pc.setRemoteDescription(signal.sdp, "offer");
|
|
376
|
+
// `onLocalDescription` fires automatically with the SDP answer.
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
|
|
380
|
+
closeSession(sessionId);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (signal.type === "candidate") {
|
|
386
|
+
const pc = peers.get(sessionId);
|
|
387
|
+
if (!pc) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (typeof signal.candidate === "string" && typeof signal.mid === "string") {
|
|
391
|
+
try {
|
|
392
|
+
pc.addRemoteCandidate(signal.candidate, signal.mid);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
395
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to add candidate: ${message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Close and remove the peer connection for a session.
|
|
403
|
+
* Called automatically when the connection enters a terminal state.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} sessionId
|
|
406
|
+
* @returns {void}
|
|
407
|
+
*/
|
|
408
|
+
function closeSession(sessionId) {
|
|
409
|
+
const pc = peers.get(sessionId);
|
|
410
|
+
if (pc) {
|
|
411
|
+
try { pc.close(); } catch { /* ignore */ }
|
|
412
|
+
peers.delete(sessionId);
|
|
413
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: closed`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Close all peer connections and stop the shared UDP mux listener.
|
|
419
|
+
*
|
|
420
|
+
* @returns {void}
|
|
421
|
+
*/
|
|
422
|
+
function dispose() {
|
|
423
|
+
for (const sessionId of [...peers.keys()]) {
|
|
424
|
+
closeSession(sessionId);
|
|
425
|
+
}
|
|
426
|
+
if (udpMuxListener) {
|
|
427
|
+
try { udpMuxListener.stop(); } catch { /* ignore */ }
|
|
428
|
+
udpMuxListener = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* What the transport itself is doing for one session.
|
|
434
|
+
*
|
|
435
|
+
* The discriminator for a send queue that stops draining. `bufferedAmount`
|
|
436
|
+
* alone says only that bytes are stuck in OUR queue; these say whether they
|
|
437
|
+
* are leaving the machine:
|
|
438
|
+
*
|
|
439
|
+
* - `bytesSent` climbing while the queue climbs → packets ARE going out and
|
|
440
|
+
* nothing is acknowledging them: the path back is broken.
|
|
441
|
+
* - `bytesSent` flat while the queue climbs → SCTP is not transmitting at
|
|
442
|
+
* all: the peer's receive window is closed, or congestion control has
|
|
443
|
+
* collapsed. `bytesReceived` still climbing at the same time proves the
|
|
444
|
+
* peer is alive and its packets still reach us, i.e. the failure is
|
|
445
|
+
* one-directional.
|
|
446
|
+
* - both flat → nothing crosses in either direction.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} sessionId
|
|
449
|
+
* @returns {{ bytesSent: number, bytesReceived: number, rtt: number, pair: string, remote: { address: string, port: number } | null, state: string, iceState: string } | null}
|
|
450
|
+
*/
|
|
451
|
+
function getTransportSnapshot(sessionId) {
|
|
452
|
+
const pc = peers.get(sessionId);
|
|
453
|
+
if (!pc) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const read = (fn, fallback) => {
|
|
457
|
+
try {
|
|
458
|
+
return fn();
|
|
459
|
+
} catch {
|
|
460
|
+
return fallback;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const pair = read(() => pc.getSelectedCandidatePair(), null);
|
|
464
|
+
return {
|
|
465
|
+
bytesSent: read(() => pc.bytesSent(), -1),
|
|
466
|
+
bytesReceived: read(() => pc.bytesReceived(), -1),
|
|
467
|
+
rtt: read(() => pc.rtt(), -1),
|
|
468
|
+
pair: pair
|
|
469
|
+
? `${pair.local?.address}:${pair.local?.port}->${pair.remote?.address}:${pair.remote?.port}` +
|
|
470
|
+
` (${pair.local?.type}/${pair.remote?.type})`
|
|
471
|
+
: "none",
|
|
472
|
+
// Structured, for consumers that need to act on the endpoint itself (the
|
|
473
|
+
// packet witness builds a tcpdump filter from it) rather than print it.
|
|
474
|
+
remote:
|
|
475
|
+
typeof pair?.remote?.address === "string" && Number.isFinite(pair?.remote?.port)
|
|
476
|
+
? { address: pair.remote.address, port: pair.remote.port }
|
|
477
|
+
: null,
|
|
478
|
+
state: read(() => pc.state(), "?"),
|
|
479
|
+
iceState: read(() => pc.iceState(), "?")
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { handleSignal, closeSession, dispose, getTransportSnapshot };
|
|
484
|
+
}
|