@torrent-tv/proxy 2.87.21 → 2.88.1
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 +2242 -2234
- 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 +1 -1
- 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 +50 -50
- 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/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +106 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- 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 +138 -138
- package/routes/api/transcode-sessions/post.js +173 -173
- 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 +380 -380
- 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/server.js +725 -725
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- 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 +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2184
- 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 +1141 -1141
- 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 +315 -315
- 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 +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +515 -515
- 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 +245 -245
- 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 +339 -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/playback-planner.js +931 -931
- 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 +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -992
- 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 +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1931
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -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/episode-naming.js +182 -0
- 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 +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- 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 +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- 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 +691 -691
- 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 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +212 -212
- 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/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- 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 +162 -162
- 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/episode-naming.test.js +131 -0
- 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-presentation-clock.test.js +147 -147
- 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 +401 -401
- 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 +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -403
- 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/playback-readiness.test.js +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- 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 +12 -2
- package/test/source-registry.test.js +18 -18
- 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 +209 -209
- 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-metadata.test.js +15 -0
- 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/utils/torrent-source-key.js +38 -38
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The worker transport's memory contract.
|
|
3
|
-
*
|
|
4
|
-
* These cover the defect that broke playback in 2.9.71-2.9.73: the worker
|
|
5
|
-
* handed the main thread ownership of memory it did not own. WebTorrent's piece
|
|
6
|
-
* cache keeps the buffer it returns and slices it again on the next read, so
|
|
7
|
-
* transferring it detached the cache's own memory and every later read failed
|
|
8
|
-
* with "Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer".
|
|
9
|
-
*
|
|
10
|
-
* The field could not tell us this, because the error never reached anyone: the
|
|
11
|
-
* worker sent READ_END from its `finally` before the error was posted, and the
|
|
12
|
-
* main thread had no handler for a read error at all. So a failed read looked
|
|
13
|
-
* exactly like an empty file.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import test from "node:test";
|
|
17
|
-
import assert from "node:assert/strict";
|
|
18
|
-
import { MessageChannel } from "node:worker_threads";
|
|
19
|
-
import { createSendStream, createReceiveStream, createCaller } from "../services/torrent/worker/channel.js";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A buffer standing in for one owned by WebTorrent's piece cache: allocated
|
|
23
|
-
* outside Node's shared pool, covering its whole region, and still referenced
|
|
24
|
-
* by its owner after we hand it on.
|
|
25
|
-
*
|
|
26
|
-
* @param {number} size
|
|
27
|
-
* @param {number} fill
|
|
28
|
-
* @returns {Buffer}
|
|
29
|
-
*/
|
|
30
|
-
function foreignPiece(size, fill) {
|
|
31
|
-
const piece = Buffer.allocUnsafeSlow(size);
|
|
32
|
-
piece.fill(fill);
|
|
33
|
-
return piece;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test("sending a chunk leaves the source buffer usable by its owner", async () => {
|
|
37
|
-
const { port1, port2 } = new MessageChannel();
|
|
38
|
-
try {
|
|
39
|
-
const sender = createSendStream({ port: port1, requestId: 1 });
|
|
40
|
-
const piece = foreignPiece(1024 * 1024, 7);
|
|
41
|
-
|
|
42
|
-
await sender.send(piece);
|
|
43
|
-
|
|
44
|
-
// The owner reads its own buffer again, exactly as the piece cache does on
|
|
45
|
-
// the next request for the same piece.
|
|
46
|
-
assert.equal(piece.length, 1024 * 1024, "buffer was detached by the send");
|
|
47
|
-
assert.equal(piece[0], 7);
|
|
48
|
-
assert.equal(piece.subarray(0, 16).length, 16, "slicing the source failed");
|
|
49
|
-
} finally {
|
|
50
|
-
port1.close();
|
|
51
|
-
port2.close();
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("a second read of the same piece still returns its bytes", async () => {
|
|
56
|
-
const { port1, port2 } = new MessageChannel();
|
|
57
|
-
try {
|
|
58
|
-
const piece = foreignPiece(512 * 1024, 3);
|
|
59
|
-
|
|
60
|
-
for (const requestId of [1, 2]) {
|
|
61
|
-
const sender = createSendStream({ port: port1, requestId });
|
|
62
|
-
await sender.send(piece);
|
|
63
|
-
sender.end();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
assert.equal(piece[0], 3, "the piece did not survive being sent twice");
|
|
67
|
-
} finally {
|
|
68
|
-
port1.close();
|
|
69
|
-
port2.close();
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("chunks arrive with their contents intact", async () => {
|
|
74
|
-
const { port1, port2 } = new MessageChannel();
|
|
75
|
-
try {
|
|
76
|
-
const received = [];
|
|
77
|
-
// Waited on rather than slept through. This test used to sleep 50 ms and
|
|
78
|
-
// then assert, so it failed whenever the run was busy — recorded as flaky in
|
|
79
|
-
// roadmap items 53 and 54, and it failed again on 2026-09-02 for a reason
|
|
80
|
-
// that shows exactly how little a chosen interval is worth: the worker had
|
|
81
|
-
// gained one more module to load, and 50 ms stopped being enough.
|
|
82
|
-
let arrived;
|
|
83
|
-
const firstChunk = new Promise((resolve) => { arrived = resolve; });
|
|
84
|
-
port2.on("message", (message) => {
|
|
85
|
-
if (message?.type === "chunk") {
|
|
86
|
-
received.push(Buffer.from(message.bytes));
|
|
87
|
-
arrived();
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const sender = createSendStream({ port: port1, requestId: 1 });
|
|
92
|
-
const piece = foreignPiece(256 * 1024, 42);
|
|
93
|
-
await sender.send(piece);
|
|
94
|
-
sender.end();
|
|
95
|
-
|
|
96
|
-
await firstChunk;
|
|
97
|
-
assert.equal(received.length, 1);
|
|
98
|
-
assert.equal(received[0].length, 256 * 1024);
|
|
99
|
-
assert.equal(received[0][0], 42);
|
|
100
|
-
assert.equal(received[0][received[0].length - 1], 42);
|
|
101
|
-
} finally {
|
|
102
|
-
port1.close();
|
|
103
|
-
port2.close();
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("reads and commands never share a request id", () => {
|
|
108
|
-
const { port1, port2 } = new MessageChannel();
|
|
109
|
-
try {
|
|
110
|
-
const commandIds = [];
|
|
111
|
-
port2.on("message", (message) => commandIds.push(message.id));
|
|
112
|
-
|
|
113
|
-
const caller = createCaller(port1);
|
|
114
|
-
const readIds = [];
|
|
115
|
-
|
|
116
|
-
// Interleaved exactly as the real client does it: commands through `call`,
|
|
117
|
-
// reads taking an id directly, both over the same channel.
|
|
118
|
-
for (let round = 0; round < 50; round += 1) {
|
|
119
|
-
void caller.call("noop", {});
|
|
120
|
-
readIds.push(caller.nextId());
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// A repeat between the two sequences is the collision that let a read's
|
|
124
|
-
// reply resolve a command — with its result, silently.
|
|
125
|
-
const everyId = [...commandIds, ...readIds];
|
|
126
|
-
assert.equal(
|
|
127
|
-
new Set(everyId).size,
|
|
128
|
-
everyId.length,
|
|
129
|
-
"an id was handed out to both a command and a read"
|
|
130
|
-
);
|
|
131
|
-
} finally {
|
|
132
|
-
port1.close();
|
|
133
|
-
port2.close();
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("a failed read surfaces on the reader instead of ending quietly", async () => {
|
|
138
|
-
const { port1, port2 } = new MessageChannel();
|
|
139
|
-
try {
|
|
140
|
-
const receive = createReceiveStream({
|
|
141
|
-
port: port1,
|
|
142
|
-
requestId: 1,
|
|
143
|
-
onCancel: () => undefined
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
receive.fail(new Error("read failed in the worker"));
|
|
147
|
-
|
|
148
|
-
const reader = receive.stream.getReader();
|
|
149
|
-
await assert.rejects(
|
|
150
|
-
() => reader.read(),
|
|
151
|
-
/read failed in the worker/,
|
|
152
|
-
"the reader saw a clean end instead of the failure"
|
|
153
|
-
);
|
|
154
|
-
} finally {
|
|
155
|
-
port1.close();
|
|
156
|
-
port2.close();
|
|
157
|
-
}
|
|
158
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file The worker transport's memory contract.
|
|
3
|
+
*
|
|
4
|
+
* These cover the defect that broke playback in 2.9.71-2.9.73: the worker
|
|
5
|
+
* handed the main thread ownership of memory it did not own. WebTorrent's piece
|
|
6
|
+
* cache keeps the buffer it returns and slices it again on the next read, so
|
|
7
|
+
* transferring it detached the cache's own memory and every later read failed
|
|
8
|
+
* with "Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer".
|
|
9
|
+
*
|
|
10
|
+
* The field could not tell us this, because the error never reached anyone: the
|
|
11
|
+
* worker sent READ_END from its `finally` before the error was posted, and the
|
|
12
|
+
* main thread had no handler for a read error at all. So a failed read looked
|
|
13
|
+
* exactly like an empty file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { MessageChannel } from "node:worker_threads";
|
|
19
|
+
import { createSendStream, createReceiveStream, createCaller } from "../services/torrent/worker/channel.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A buffer standing in for one owned by WebTorrent's piece cache: allocated
|
|
23
|
+
* outside Node's shared pool, covering its whole region, and still referenced
|
|
24
|
+
* by its owner after we hand it on.
|
|
25
|
+
*
|
|
26
|
+
* @param {number} size
|
|
27
|
+
* @param {number} fill
|
|
28
|
+
* @returns {Buffer}
|
|
29
|
+
*/
|
|
30
|
+
function foreignPiece(size, fill) {
|
|
31
|
+
const piece = Buffer.allocUnsafeSlow(size);
|
|
32
|
+
piece.fill(fill);
|
|
33
|
+
return piece;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("sending a chunk leaves the source buffer usable by its owner", async () => {
|
|
37
|
+
const { port1, port2 } = new MessageChannel();
|
|
38
|
+
try {
|
|
39
|
+
const sender = createSendStream({ port: port1, requestId: 1 });
|
|
40
|
+
const piece = foreignPiece(1024 * 1024, 7);
|
|
41
|
+
|
|
42
|
+
await sender.send(piece);
|
|
43
|
+
|
|
44
|
+
// The owner reads its own buffer again, exactly as the piece cache does on
|
|
45
|
+
// the next request for the same piece.
|
|
46
|
+
assert.equal(piece.length, 1024 * 1024, "buffer was detached by the send");
|
|
47
|
+
assert.equal(piece[0], 7);
|
|
48
|
+
assert.equal(piece.subarray(0, 16).length, 16, "slicing the source failed");
|
|
49
|
+
} finally {
|
|
50
|
+
port1.close();
|
|
51
|
+
port2.close();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a second read of the same piece still returns its bytes", async () => {
|
|
56
|
+
const { port1, port2 } = new MessageChannel();
|
|
57
|
+
try {
|
|
58
|
+
const piece = foreignPiece(512 * 1024, 3);
|
|
59
|
+
|
|
60
|
+
for (const requestId of [1, 2]) {
|
|
61
|
+
const sender = createSendStream({ port: port1, requestId });
|
|
62
|
+
await sender.send(piece);
|
|
63
|
+
sender.end();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assert.equal(piece[0], 3, "the piece did not survive being sent twice");
|
|
67
|
+
} finally {
|
|
68
|
+
port1.close();
|
|
69
|
+
port2.close();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("chunks arrive with their contents intact", async () => {
|
|
74
|
+
const { port1, port2 } = new MessageChannel();
|
|
75
|
+
try {
|
|
76
|
+
const received = [];
|
|
77
|
+
// Waited on rather than slept through. This test used to sleep 50 ms and
|
|
78
|
+
// then assert, so it failed whenever the run was busy — recorded as flaky in
|
|
79
|
+
// roadmap items 53 and 54, and it failed again on 2026-09-02 for a reason
|
|
80
|
+
// that shows exactly how little a chosen interval is worth: the worker had
|
|
81
|
+
// gained one more module to load, and 50 ms stopped being enough.
|
|
82
|
+
let arrived;
|
|
83
|
+
const firstChunk = new Promise((resolve) => { arrived = resolve; });
|
|
84
|
+
port2.on("message", (message) => {
|
|
85
|
+
if (message?.type === "chunk") {
|
|
86
|
+
received.push(Buffer.from(message.bytes));
|
|
87
|
+
arrived();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const sender = createSendStream({ port: port1, requestId: 1 });
|
|
92
|
+
const piece = foreignPiece(256 * 1024, 42);
|
|
93
|
+
await sender.send(piece);
|
|
94
|
+
sender.end();
|
|
95
|
+
|
|
96
|
+
await firstChunk;
|
|
97
|
+
assert.equal(received.length, 1);
|
|
98
|
+
assert.equal(received[0].length, 256 * 1024);
|
|
99
|
+
assert.equal(received[0][0], 42);
|
|
100
|
+
assert.equal(received[0][received[0].length - 1], 42);
|
|
101
|
+
} finally {
|
|
102
|
+
port1.close();
|
|
103
|
+
port2.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("reads and commands never share a request id", () => {
|
|
108
|
+
const { port1, port2 } = new MessageChannel();
|
|
109
|
+
try {
|
|
110
|
+
const commandIds = [];
|
|
111
|
+
port2.on("message", (message) => commandIds.push(message.id));
|
|
112
|
+
|
|
113
|
+
const caller = createCaller(port1);
|
|
114
|
+
const readIds = [];
|
|
115
|
+
|
|
116
|
+
// Interleaved exactly as the real client does it: commands through `call`,
|
|
117
|
+
// reads taking an id directly, both over the same channel.
|
|
118
|
+
for (let round = 0; round < 50; round += 1) {
|
|
119
|
+
void caller.call("noop", {});
|
|
120
|
+
readIds.push(caller.nextId());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A repeat between the two sequences is the collision that let a read's
|
|
124
|
+
// reply resolve a command — with its result, silently.
|
|
125
|
+
const everyId = [...commandIds, ...readIds];
|
|
126
|
+
assert.equal(
|
|
127
|
+
new Set(everyId).size,
|
|
128
|
+
everyId.length,
|
|
129
|
+
"an id was handed out to both a command and a read"
|
|
130
|
+
);
|
|
131
|
+
} finally {
|
|
132
|
+
port1.close();
|
|
133
|
+
port2.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a failed read surfaces on the reader instead of ending quietly", async () => {
|
|
138
|
+
const { port1, port2 } = new MessageChannel();
|
|
139
|
+
try {
|
|
140
|
+
const receive = createReceiveStream({
|
|
141
|
+
port: port1,
|
|
142
|
+
requestId: 1,
|
|
143
|
+
onCancel: () => undefined
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
receive.fail(new Error("read failed in the worker"));
|
|
147
|
+
|
|
148
|
+
const reader = receive.stream.getReader();
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => reader.read(),
|
|
151
|
+
/read failed in the worker/,
|
|
152
|
+
"the reader saw a clean end instead of the failure"
|
|
153
|
+
);
|
|
154
|
+
} finally {
|
|
155
|
+
port1.close();
|
|
156
|
+
port2.close();
|
|
157
|
+
}
|
|
158
|
+
});
|
package/utils/client-log-file.js
CHANGED
|
@@ -1,151 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
-
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
-
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
-
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
-
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
-
* the evidence for exactly this reason.
|
|
10
|
-
*
|
|
11
|
-
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
-
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
-
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
/** One file may reach this before it is rotated. */
|
|
20
|
-
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
-
*
|
|
25
|
-
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
-
* and it is about to become a path.
|
|
27
|
-
*
|
|
28
|
-
* @param {string} value
|
|
29
|
-
* @param {number} max
|
|
30
|
-
* @returns {string}
|
|
31
|
-
*/
|
|
32
|
-
function safeName(value, max) {
|
|
33
|
-
const cleaned = String(value ?? "")
|
|
34
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
-
.replace(/-+/g, "-")
|
|
36
|
-
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
-
return cleaned.slice(0, max);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
-
*
|
|
43
|
-
* @param {string} iso
|
|
44
|
-
* @returns {string}
|
|
45
|
-
*/
|
|
46
|
-
function stamp(iso) {
|
|
47
|
-
const when = new Date(iso);
|
|
48
|
-
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
-
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
-
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A sink that writes one session's browser lines to its own file.
|
|
56
|
-
*
|
|
57
|
-
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
-
* used, so the two halves are always side by side.
|
|
59
|
-
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
-
*/
|
|
61
|
-
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
-
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
-
? dirname(proxyLogPath)
|
|
64
|
-
: "";
|
|
65
|
-
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
-
const open = new Map();
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The file for this session, opened on first use.
|
|
70
|
-
*
|
|
71
|
-
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
-
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
-
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
-
* same session and must not start a second file.
|
|
75
|
-
*
|
|
76
|
-
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
-
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
-
*/
|
|
79
|
-
function fileFor(session) {
|
|
80
|
-
if (!directory) return null;
|
|
81
|
-
const key = session.sessionId;
|
|
82
|
-
const existing = open.get(key);
|
|
83
|
-
if (existing) return existing;
|
|
84
|
-
const film = session.torrentName
|
|
85
|
-
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
-
: "no-torrent-yet";
|
|
87
|
-
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
-
try {
|
|
89
|
-
mkdirSync(directory, { recursive: true });
|
|
90
|
-
const record = {
|
|
91
|
-
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
-
path,
|
|
93
|
-
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
-
};
|
|
95
|
-
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
-
// and must not be retried on every line.
|
|
97
|
-
record.stream.on("error", () => open.delete(key));
|
|
98
|
-
open.set(key, record);
|
|
99
|
-
return record;
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
write(session, lines) {
|
|
107
|
-
const record = fileFor(session);
|
|
108
|
-
if (!record) return;
|
|
109
|
-
for (const line of lines) {
|
|
110
|
-
const text = `${line}\n`;
|
|
111
|
-
record.bytes += Buffer.byteLength(text);
|
|
112
|
-
record.stream.write(text);
|
|
113
|
-
}
|
|
114
|
-
if (record.bytes >= MAX_BYTES) {
|
|
115
|
-
try {
|
|
116
|
-
record.stream.end(() => {
|
|
117
|
-
try {
|
|
118
|
-
renameSync(record.path, `${record.path}.1`);
|
|
119
|
-
} catch {
|
|
120
|
-
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
} catch {
|
|
124
|
-
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
-
// better than losing the session's log to an exception.
|
|
126
|
-
}
|
|
127
|
-
open.delete(session.sessionId);
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
/**
|
|
131
|
-
* Finish every open file.
|
|
132
|
-
*
|
|
133
|
-
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
-
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
-
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
-
*
|
|
137
|
-
* @returns {Promise<void>}
|
|
138
|
-
*/
|
|
139
|
-
async close() {
|
|
140
|
-
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
-
try {
|
|
142
|
-
record.stream.end(resolve);
|
|
143
|
-
} catch {
|
|
144
|
-
resolve(undefined);
|
|
145
|
-
}
|
|
146
|
-
}));
|
|
147
|
-
open.clear();
|
|
148
|
-
await Promise.all(ending);
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
+
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
+
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
+
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
+
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
+
* the evidence for exactly this reason.
|
|
10
|
+
*
|
|
11
|
+
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
+
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
+
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** One file may reach this before it is rotated. */
|
|
20
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
+
* and it is about to become a path.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value
|
|
29
|
+
* @param {number} max
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function safeName(value, max) {
|
|
33
|
+
const cleaned = String(value ?? "")
|
|
34
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
+
.replace(/-+/g, "-")
|
|
36
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
+
return cleaned.slice(0, max);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} iso
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function stamp(iso) {
|
|
47
|
+
const when = new Date(iso);
|
|
48
|
+
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
+
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
+
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A sink that writes one session's browser lines to its own file.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
+
* used, so the two halves are always side by side.
|
|
59
|
+
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
+
*/
|
|
61
|
+
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
+
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
+
? dirname(proxyLogPath)
|
|
64
|
+
: "";
|
|
65
|
+
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
+
const open = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The file for this session, opened on first use.
|
|
70
|
+
*
|
|
71
|
+
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
+
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
+
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
+
* same session and must not start a second file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
+
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
+
*/
|
|
79
|
+
function fileFor(session) {
|
|
80
|
+
if (!directory) return null;
|
|
81
|
+
const key = session.sessionId;
|
|
82
|
+
const existing = open.get(key);
|
|
83
|
+
if (existing) return existing;
|
|
84
|
+
const film = session.torrentName
|
|
85
|
+
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
+
: "no-torrent-yet";
|
|
87
|
+
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(directory, { recursive: true });
|
|
90
|
+
const record = {
|
|
91
|
+
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
+
path,
|
|
93
|
+
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
+
};
|
|
95
|
+
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
+
// and must not be retried on every line.
|
|
97
|
+
record.stream.on("error", () => open.delete(key));
|
|
98
|
+
open.set(key, record);
|
|
99
|
+
return record;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
write(session, lines) {
|
|
107
|
+
const record = fileFor(session);
|
|
108
|
+
if (!record) return;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const text = `${line}\n`;
|
|
111
|
+
record.bytes += Buffer.byteLength(text);
|
|
112
|
+
record.stream.write(text);
|
|
113
|
+
}
|
|
114
|
+
if (record.bytes >= MAX_BYTES) {
|
|
115
|
+
try {
|
|
116
|
+
record.stream.end(() => {
|
|
117
|
+
try {
|
|
118
|
+
renameSync(record.path, `${record.path}.1`);
|
|
119
|
+
} catch {
|
|
120
|
+
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
+
// better than losing the session's log to an exception.
|
|
126
|
+
}
|
|
127
|
+
open.delete(session.sessionId);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
/**
|
|
131
|
+
* Finish every open file.
|
|
132
|
+
*
|
|
133
|
+
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
+
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
+
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async close() {
|
|
140
|
+
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
+
try {
|
|
142
|
+
record.stream.end(resolve);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(undefined);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
open.clear();
|
|
148
|
+
await Promise.all(ending);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|