@bubo-squared/gyroview 0.2.0 → 0.3.0
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/CHANGELOG.md +26 -0
- package/README.md +16 -5
- package/dist/index.d.ts +1 -0
- package/dist/packages/adapters/fetch/src/HttpByteStream.js +174 -0
- package/dist/packages/adapters/fetch/src/HttpRangeSource.js +14 -170
- package/dist/packages/adapters/fetch/src/HttpResource.js +192 -0
- package/dist/packages/adapters/fetch/src/contentRange.js +15 -0
- package/dist/packages/adapters/fetch/src/httpRequest.js +5 -3
- package/dist/packages/adapters/fetch/src/index.js +3 -1
- package/dist/packages/adapters/fetch/src/passingFailures.js +24 -0
- package/dist/packages/adapters/fetch/src/rangeFailures.js +17 -0
- package/dist/packages/adapters/fetch/src/recordingVersion.js +37 -0
- package/dist/packages/adapters/mediabunny/src/MediabunnyAudioPackager.js +129 -0
- package/dist/packages/adapters/mediabunny/src/MediabunnyCodecReader.js +80 -0
- package/dist/packages/adapters/mediabunny/src/SegmentChannel.js +58 -6
- package/dist/packages/adapters/mediabunny/src/decoderConfigurations.js +28 -0
- package/dist/packages/adapters/mediabunny/src/index.js +3 -2
- package/dist/packages/core/src/application/download/BlockStore.js +97 -0
- package/dist/packages/core/src/application/download/DownloadedAudioSamples.js +50 -0
- package/dist/packages/core/src/application/download/DownloadedVideoTrack.js +122 -0
- package/dist/packages/core/src/application/download/FileDownload.js +183 -0
- package/dist/packages/core/src/application/download/RecordingBuffer.js +22 -0
- package/dist/packages/core/src/application/download/SampleCursor.js +88 -0
- package/dist/packages/core/src/application/download/SourceByteStream.js +46 -0
- package/dist/packages/core/src/application/download/Transfers.js +75 -0
- package/dist/packages/core/src/application/download/startFileDownload.js +37 -0
- package/dist/packages/core/src/application/playback/Buffering.js +53 -0
- package/dist/packages/core/src/application/playback/DecodePipeline.js +11 -7
- package/dist/packages/core/src/application/playback/DecodeRun.js +15 -0
- package/dist/packages/core/src/application/playback/PlaybackSession.js +18 -27
- package/dist/packages/core/src/application/playback/keyframeTimeAt.js +1 -1
- package/dist/packages/core/src/application/playback/probeDecoding.js +15 -1
- package/dist/packages/core/src/application/recording/readSampleTable.js +27 -0
- package/dist/packages/core/src/application/recording/timeRecording.js +3 -3
- package/dist/packages/core/src/domain/container/KeyframeRule.js +7 -0
- package/dist/packages/core/src/domain/container/SampleTable.js +29 -0
- package/dist/packages/core/src/domain/container/TrackSampleTable.js +96 -0
- package/dist/packages/core/src/domain/container/presentationOrder.js +10 -0
- package/dist/packages/core/src/domain/download/DownloadPolicy.js +41 -0
- package/dist/packages/core/src/domain/download/isReadyToResume.js +27 -0
- package/dist/packages/core/src/domain/download/planDownloads.js +175 -0
- package/dist/packages/core/src/domain/download/trackNeeds.js +32 -0
- package/dist/packages/core/src/domain/format/boxes/boxHeader.js +44 -0
- package/dist/packages/core/src/domain/format/boxes/scanBoxes.js +7 -34
- package/dist/packages/core/src/domain/format/mp4/keyframeRules.js +81 -0
- package/dist/packages/core/src/domain/format/mp4/movieBoxes.js +52 -0
- package/dist/packages/core/src/domain/format/mp4/mp4BoxTypes.js +46 -0
- package/dist/packages/core/src/domain/format/mp4/mp4Layouts.js +183 -0
- package/dist/packages/core/src/domain/format/mp4/parseMovie.js +67 -0
- package/dist/packages/core/src/domain/format/mp4/readTable.js +31 -0
- package/dist/packages/core/src/domain/format/mp4/sampleLocations.js +69 -0
- package/dist/packages/core/src/domain/format/mp4/sampleTiming.js +109 -0
- package/dist/packages/core/src/domain/format/mp4/trackHeaders.js +75 -0
- package/dist/packages/core/src/index.js +9 -1
- package/dist/packages/core/src/shared/async/iteration.js +24 -0
- package/dist/packages/core/src/shared/binary/ByteRange.js +12 -0
- package/dist/packages/core/src/shared/binary/ByteRangeSet.js +85 -0
- package/dist/packages/core/src/shared/binary/ByteReader.js +21 -3
- package/dist/packages/core/src/shared/binary/concatenated.js +15 -0
- package/dist/packages/core/src/shared/errors/GyroViewError.js +1 -0
- package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
- package/dist/packages/player/src/composition/browserPorts.js +23 -4
- package/dist/packages/player/src/composition/buildPipeline.js +15 -3
- package/dist/packages/player/src/composition/openInputs.js +51 -107
- package/dist/packages/player/src/composition/readInputs.js +111 -0
- package/dist/packages/player/src/controls/messages.js +1 -0
- package/dist/packages/player/src/inspectRecording.js +1 -1
- package/dist/standalone.js +58 -58
- package/package.json +1 -1
- package/dist/packages/adapters/mediabunny/src/MediabunnyAudioSegments.js +0 -133
- package/dist/packages/adapters/mediabunny/src/MediabunnyDemuxer.js +0 -63
- package/dist/packages/adapters/mediabunny/src/MediabunnyVideoTrackReader.js +0 -84
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
What changed for a page using the package, newest first. Until 1.0, a minor version may change
|
|
4
4
|
the API.
|
|
5
5
|
|
|
6
|
+
## 0.3.0 (2026-09-30)
|
|
7
|
+
|
|
8
|
+
What a page may notice:
|
|
9
|
+
|
|
10
|
+
- A recording named by URL is downloaded in file order as it plays, the picture and the sound
|
|
11
|
+
from the same bytes, each fetched about once (ADR 0029). Over a simulated link a little
|
|
12
|
+
slower than an X5 recording at its highest setting (200 Mbit/s against 210), it starts in
|
|
13
|
+
half the time, fetches under half the bytes, and after a seek shows the target in half the
|
|
14
|
+
time, asking for nothing of the old position.
|
|
15
|
+
|
|
16
|
+
| State | What it downloads |
|
|
17
|
+
| ---------------------- | --------------------------------------------------------------------- |
|
|
18
|
+
| Loaded, never played | What opening and the first frame need; with `preload="none"` no frame |
|
|
19
|
+
| Playing | Up to 10 s or 128 MiB ahead, whichever is less, topped up as it plays |
|
|
20
|
+
| Paused after playing | On up to that budget, then nothing |
|
|
21
|
+
| A seek | The old position's requests end at once; the target first |
|
|
22
|
+
| Starved by the network | Waits until the next 4 s are downloaded, then plays on |
|
|
23
|
+
|
|
24
|
+
- A range that breaks off or brings nothing for 10 s is asked for again from its next byte.
|
|
25
|
+
|
|
26
|
+
New:
|
|
27
|
+
|
|
28
|
+
- The `source-changed` error: the recording at the URL was replaced while it played, as its
|
|
29
|
+
`ETag` tells, or else its `Last-Modified` and size. Exposing `ETag` across origins is
|
|
30
|
+
optional, and now named in the CORS advice.
|
|
31
|
+
|
|
6
32
|
## 0.2.0 (2026-09-29)
|
|
7
33
|
|
|
8
34
|
What a page may notice:
|
package/README.md
CHANGED
|
@@ -42,10 +42,10 @@ A page without a bundler loads the standalone file, which has Three.js and media
|
|
|
42
42
|
```html
|
|
43
43
|
<script
|
|
44
44
|
type="module"
|
|
45
|
-
src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.
|
|
45
|
+
src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js"
|
|
46
46
|
></script>
|
|
47
47
|
<script type="module">
|
|
48
|
-
import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.
|
|
48
|
+
import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js';
|
|
49
49
|
</script>
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -116,8 +116,19 @@ player.lookAt(90, 0);
|
|
|
116
116
|
|
|
117
117
|
The canvas keeps no picture once the browser has shown it: a snapshot (`drawImage`, `toBlob`)
|
|
118
118
|
is taken in a `frame` listener, which runs right after each picture is drawn. There are no
|
|
119
|
-
buffered ranges to show,
|
|
120
|
-
|
|
119
|
+
buffered ranges to show yet, and no playback rates other than 1.
|
|
120
|
+
|
|
121
|
+
## How a recording is downloaded
|
|
122
|
+
|
|
123
|
+
A recording named by URL is read in byte ranges, in file order, as it plays: the lenses and
|
|
124
|
+
the sound come from the same bytes, each fetched about once. Until the first play the player
|
|
125
|
+
reads only what opening it and showing the first frame need (no frame with `preload="none"`).
|
|
126
|
+
Playing, it keeps up to 10 s or 128 MiB ahead, whichever is less, shared by the two files of a
|
|
127
|
+
split recording; paused, it reads on to that budget and stops. A seek ends the old position's
|
|
128
|
+
requests at once. When the network falls behind the recording, playback waits until the next
|
|
129
|
+
4 s are downloaded, and plays on in stretches rather than a frame at a time. A range that
|
|
130
|
+
breaks off or stalls for 10 s is asked for again from its next byte, and a recording replaced at
|
|
131
|
+
its URL while it plays fails with `source-changed`.
|
|
121
132
|
|
|
122
133
|
`@bubo-squared/gyroview` also exports `GyroViewError`, the list of its codes
|
|
123
134
|
(`GYRO_VIEW_ERROR_CODES`, with `isGyroViewErrorCode` to check a string against it), and the
|
|
@@ -185,7 +196,7 @@ loads a recording only for the player in view, removing `src` from the others.
|
|
|
185
196
|
Access-Control-Allow-Origin: https://your-site.example
|
|
186
197
|
Access-Control-Allow-Methods: GET, HEAD
|
|
187
198
|
Access-Control-Allow-Headers: Range
|
|
188
|
-
Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges
|
|
199
|
+
Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges, ETag
|
|
189
200
|
```
|
|
190
201
|
|
|
191
202
|
Recordings kept behind the visitor's cookies take `crossorigin="use-credentials"` on the
|
package/dist/index.d.ts
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { GyroViewError, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
|
|
2
|
+
import { ByteRange } from "../../../core/src/shared/binary/ByteRange.js";
|
|
3
|
+
import { Ending, ITERATION_END } from "../../../core/src/shared/async/iteration.js";
|
|
4
|
+
import "../../../core/src/index.js";
|
|
5
|
+
import { contentRangeOf } from "./contentRange.js";
|
|
6
|
+
import { passing } from "./passingFailures.js";
|
|
7
|
+
import { brokenOff, truncated } from "./rangeFailures.js";
|
|
8
|
+
//#region ../../packages/adapters/fetch/src/HttpByteStream.ts
|
|
9
|
+
/**
|
|
10
|
+
* Long enough for a slow mobile network to deliver its next packet, short enough that playback
|
|
11
|
+
* waiting on a dead connection recovers well before the viewer gives up.
|
|
12
|
+
*/
|
|
13
|
+
var DEFAULT_STALL_TIMEOUT_MS = 1e4;
|
|
14
|
+
/**
|
|
15
|
+
* ByteStream over HTTP: a range is one request, its body handed on a chunk at a time as it
|
|
16
|
+
* comes, and given up by aborting the request. A body that breaks off or stalls is asked for
|
|
17
|
+
* again from its next byte, as the resource's retry rules allow.
|
|
18
|
+
*/
|
|
19
|
+
var HttpByteStream = class {
|
|
20
|
+
resource;
|
|
21
|
+
stallTimeoutMs;
|
|
22
|
+
constructor(resource, options = {}) {
|
|
23
|
+
this.resource = resource;
|
|
24
|
+
this.stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
25
|
+
}
|
|
26
|
+
stream(range) {
|
|
27
|
+
return { [Symbol.asyncIterator]: () => new RangeReading({
|
|
28
|
+
resource: this.resource,
|
|
29
|
+
range,
|
|
30
|
+
stallTimeoutMs: this.stallTimeoutMs
|
|
31
|
+
}) };
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* One range's requests and bodies. The answer must be the bytes asked for, every one and no
|
|
36
|
+
* more; returning the iteration aborts the request at once, a chunk still awaited coming as the
|
|
37
|
+
* end.
|
|
38
|
+
*/
|
|
39
|
+
var RangeReading = class {
|
|
40
|
+
parts;
|
|
41
|
+
attempt;
|
|
42
|
+
received = 0;
|
|
43
|
+
ending = new Ending();
|
|
44
|
+
constructor(parts) {
|
|
45
|
+
this.parts = parts;
|
|
46
|
+
}
|
|
47
|
+
async next() {
|
|
48
|
+
try {
|
|
49
|
+
const chunk = await this.parts.resource.askingAgain(() => this.readChunk());
|
|
50
|
+
if (chunk) return {
|
|
51
|
+
done: false,
|
|
52
|
+
value: chunk
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!this.ending.hasEnded()) {
|
|
56
|
+
this.end();
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.end();
|
|
61
|
+
return ITERATION_END;
|
|
62
|
+
}
|
|
63
|
+
return() {
|
|
64
|
+
this.end();
|
|
65
|
+
return Promise.resolve(ITERATION_END);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The next chunk; none at the range's end. An attempt that fails is dropped, so the next
|
|
69
|
+
* asks anew for the rest of the range.
|
|
70
|
+
*/
|
|
71
|
+
async readChunk() {
|
|
72
|
+
const { resource, range } = this.parts;
|
|
73
|
+
await resource.ensureFits(range);
|
|
74
|
+
if (this.ending.hasEnded() || range.length === 0) return void 0;
|
|
75
|
+
this.attempt ??= this.attemptAtTheRest();
|
|
76
|
+
const { attempt } = this;
|
|
77
|
+
try {
|
|
78
|
+
const read = await this.withinStallTimeout(this.readFrom(attempt));
|
|
79
|
+
return this.taken(read);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.drop(attempt);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
attemptAtTheRest() {
|
|
86
|
+
const { range } = this.parts;
|
|
87
|
+
const rest = ByteRange.of(range.offset + this.received, range.length - this.received);
|
|
88
|
+
const request = new AbortController();
|
|
89
|
+
return {
|
|
90
|
+
request,
|
|
91
|
+
body: this.bodyOf(rest, request.signal)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async bodyOf(rest, signal) {
|
|
95
|
+
const response = await this.parts.resource.rangeAnswer(rest, signal);
|
|
96
|
+
this.ensureAnswers(response, rest);
|
|
97
|
+
return response.body?.getReader() ?? new ReadableStream().getReader();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A server may answer other bytes than asked (a cache serving a range it holds); the answer
|
|
101
|
+
* says which in its Content-Range, where the page may read it.
|
|
102
|
+
*/
|
|
103
|
+
ensureAnswers(response, rest) {
|
|
104
|
+
const answered = contentRangeOf(response);
|
|
105
|
+
if (!answered) return;
|
|
106
|
+
const asked = `${rest.offset}-${rest.end - 1}`;
|
|
107
|
+
const given = `${answered.first}-${answered.last}`;
|
|
108
|
+
if (given === asked) return;
|
|
109
|
+
throw new GyroViewError("source-unreadable", `${this.parts.resource.url} answered bytes ${given} to a request for bytes ${asked}`);
|
|
110
|
+
}
|
|
111
|
+
async readFrom(attempt) {
|
|
112
|
+
const body = await attempt.body;
|
|
113
|
+
try {
|
|
114
|
+
return await body.read();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (isAbortError(error)) throw error;
|
|
117
|
+
throw brokenOff(this.parts.resource.url, this.parts.range, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A request that brings no byte for the stall timeout, answer or body, is taken for stalled:
|
|
122
|
+
* a connection a network change left dead never fails by itself.
|
|
123
|
+
*/
|
|
124
|
+
async withinStallTimeout(pending) {
|
|
125
|
+
let timer;
|
|
126
|
+
const stalled = new Promise((_, reject) => {
|
|
127
|
+
timer = setTimeout(() => {
|
|
128
|
+
reject(this.stalled());
|
|
129
|
+
}, this.parts.stallTimeoutMs);
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
return await Promise.race([pending, stalled]);
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
stalled() {
|
|
138
|
+
const { resource, range, stallTimeoutMs } = this.parts;
|
|
139
|
+
const message = `${resource.url} sent nothing of the ${range.length}-byte range at ${range.offset} for ${stallTimeoutMs} ms`;
|
|
140
|
+
return passing(new GyroViewError("source-unreadable", message));
|
|
141
|
+
}
|
|
142
|
+
taken(read) {
|
|
143
|
+
if (read.done) {
|
|
144
|
+
this.ensureWhole();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.count(read.value);
|
|
148
|
+
return read.value;
|
|
149
|
+
}
|
|
150
|
+
count(chunk) {
|
|
151
|
+
const { resource, range } = this.parts;
|
|
152
|
+
this.received += chunk.byteLength;
|
|
153
|
+
if (this.received <= range.length) return;
|
|
154
|
+
throw new GyroViewError("source-unreadable", `${resource.url} sent more than the ${range.length}-byte range at ${range.offset}`);
|
|
155
|
+
}
|
|
156
|
+
ensureWhole() {
|
|
157
|
+
const { resource, range } = this.parts;
|
|
158
|
+
if (this.received !== range.length) throw truncated(resource.url, this.received, range);
|
|
159
|
+
}
|
|
160
|
+
drop(attempt) {
|
|
161
|
+
attempt.request.abort();
|
|
162
|
+
if (this.attempt === attempt) this.attempt = void 0;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Aborting the request once it is over, however it ended, also lets go of the listeners it
|
|
166
|
+
* put on the resource's own signal.
|
|
167
|
+
*/
|
|
168
|
+
end() {
|
|
169
|
+
this.ending.end();
|
|
170
|
+
if (this.attempt) this.drop(this.attempt);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
export { HttpByteStream };
|
|
@@ -1,194 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
|
|
2
2
|
import "../../../core/src/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { brokenOff, truncated } from "./rangeFailures.js";
|
|
4
4
|
//#region ../../packages/adapters/fetch/src/HttpRangeSource.ts
|
|
5
|
-
var HTTP_OK = 200;
|
|
6
|
-
var HTTP_PARTIAL_CONTENT = 206;
|
|
7
|
-
var HTTP_SERVER_ERROR = 500;
|
|
8
|
-
var DEFAULT_RETRY_DELAYS_MS = [250, 1e3];
|
|
9
5
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*/
|
|
14
|
-
var passingFailures = /* @__PURE__ */ new WeakSet();
|
|
15
|
-
function passing(failure) {
|
|
16
|
-
passingFailures.add(failure);
|
|
17
|
-
return failure;
|
|
18
|
-
}
|
|
19
|
-
var CONTENT_RANGE_TOTAL = /\/(\d+)$/u;
|
|
20
|
-
/**
|
|
21
|
-
* RandomAccessSource over HTTP. The server must answer `Range` requests with 206 and, for
|
|
22
|
-
* cross-origin use, send CORS headers that expose `Content-Range`; both are hard requirements
|
|
23
|
-
* of playing a remote recording and are reported with distinct error codes.
|
|
6
|
+
* RandomAccessSource over HTTP: each range read whole, in one request, asked for again when it
|
|
7
|
+
* fails on the way. It shares its resource with the recording's byte stream, so both know one
|
|
8
|
+
* size, one proof of CORS and one version.
|
|
24
9
|
*/
|
|
25
10
|
var HttpRangeSource = class {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
sizePromise;
|
|
30
|
-
/**
|
|
31
|
-
* A range has come through: CORS is proven for this source, so a request that fails from here
|
|
32
|
-
* on failed on the way, whatever the diagnosis says of an error page without CORS headers.
|
|
33
|
-
*/
|
|
34
|
-
hasReadRange = false;
|
|
35
|
-
/**
|
|
36
|
-
* `signal` ends every request the source makes, with the host's own `requestInit` signal.
|
|
37
|
-
*/
|
|
38
|
-
constructor(url, options = {}, signal) {
|
|
39
|
-
this.url = url;
|
|
40
|
-
this.options = signal ? withAbortSignal(options, signal) : options;
|
|
41
|
-
this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
11
|
+
resource;
|
|
12
|
+
constructor(resource) {
|
|
13
|
+
this.resource = resource;
|
|
42
14
|
}
|
|
43
|
-
/**
|
|
44
|
-
* One HEAD request (or a one-byte range where HEAD is refused or gives no length), cached for
|
|
45
|
-
* the lifetime of the source once it succeeded; a failed lookup is retried on the next call.
|
|
46
|
-
*/
|
|
47
15
|
size() {
|
|
48
|
-
|
|
49
|
-
return this.sizePromise;
|
|
16
|
+
return this.resource.size();
|
|
50
17
|
}
|
|
51
18
|
async read(range) {
|
|
52
|
-
await this.ensureFits(range);
|
|
53
|
-
return range.length === 0 ? /* @__PURE__ */ new Uint8Array() : this.
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* A range that failed on the way is asked for again after a short wait, a few times: one
|
|
57
|
-
* dropped connection must not end a long playback. What asking again cannot change (an abort,
|
|
58
|
-
* a refusal, a server ignoring ranges, CORS) fails at once.
|
|
59
|
-
*/
|
|
60
|
-
async readAskingAgain(range) {
|
|
61
|
-
for (const delayMs of this.retryDelaysMs) {
|
|
62
|
-
try {
|
|
63
|
-
return await this.readOnce(range);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (!(error instanceof GyroViewError && passingFailures.has(error))) throw error;
|
|
66
|
-
}
|
|
67
|
-
await wait(delayMs, this.options.requestInit?.signal);
|
|
68
|
-
}
|
|
69
|
-
return this.readOnce(range);
|
|
19
|
+
await this.resource.ensureFits(range);
|
|
20
|
+
return range.length === 0 ? /* @__PURE__ */ new Uint8Array() : this.resource.askingAgain(() => this.readOnce(range));
|
|
70
21
|
}
|
|
71
22
|
async readOnce(range) {
|
|
72
|
-
const response = await this.
|
|
73
|
-
if (response.status !== HTTP_PARTIAL_CONTENT) {
|
|
74
|
-
discardBody(response);
|
|
75
|
-
throw this.refusalOf(response.status);
|
|
76
|
-
}
|
|
77
|
-
this.hasReadRange = true;
|
|
23
|
+
const response = await this.resource.rangeAnswer(range);
|
|
78
24
|
const bytes = await this.bodyOf(response, range);
|
|
79
|
-
if (bytes.byteLength !== range.length) throw
|
|
25
|
+
if (bytes.byteLength !== range.length) throw truncated(this.resource.url, bytes.byteLength, range);
|
|
80
26
|
return bytes;
|
|
81
27
|
}
|
|
82
|
-
/**
|
|
83
|
-
* A request that did not get through for want of a network is worth another try; one CORS
|
|
84
|
-
* refused before any range came through is not.
|
|
85
|
-
*/
|
|
86
|
-
async requestRange(range) {
|
|
87
|
-
const headers = { Range: `bytes=${range.offset}-${range.end - 1}` };
|
|
88
|
-
try {
|
|
89
|
-
return this.hasReadRange ? await plainHttpRequest(this.url, {
|
|
90
|
-
method: "GET",
|
|
91
|
-
headers
|
|
92
|
-
}, this.options) : await this.request("GET", headers);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
throw this.hasReadRange ? this.passingOnceAnswered(error) : passingIfUnreachable(error);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
passingOnceAnswered(error) {
|
|
98
|
-
const message = `${this.url} could not be reached for a byte range`;
|
|
99
|
-
return isAbortError(error) ? error : passing(new GyroViewError("source-unreadable", message, { cause: error }));
|
|
100
|
-
}
|
|
101
|
-
refusalOf(status) {
|
|
102
|
-
if (status === HTTP_OK) return new GyroViewError("range-unsupported", `${this.url} ignores Range requests (answered 200 to a byte range)`);
|
|
103
|
-
const message = `${this.url} answered ${status} to a byte range`;
|
|
104
|
-
return status >= HTTP_SERVER_ERROR ? passing(new GyroViewError("source-unreadable", message)) : new GyroViewError("source-unreadable", message);
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* The bytes of a range, which a dropped connection can break off after the headers: the most
|
|
108
|
-
* common way a network fails during long playback, so it is `source-unreadable` as a refused
|
|
109
|
-
* request is, not a bug or a bad file, and worth another try.
|
|
110
|
-
*/
|
|
111
28
|
async bodyOf(response, range) {
|
|
112
29
|
try {
|
|
113
30
|
return new Uint8Array(await response.arrayBuffer());
|
|
114
31
|
} catch (error) {
|
|
115
32
|
if (isAbortError(error)) throw error;
|
|
116
|
-
throw
|
|
33
|
+
throw brokenOff(this.resource.url, range, error);
|
|
117
34
|
}
|
|
118
35
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Servers clamp out-of-range requests instead of failing them; the port contract wants a
|
|
121
|
-
* typed error, so the range is checked against the (cached) size first.
|
|
122
|
-
*/
|
|
123
|
-
async ensureFits(range) {
|
|
124
|
-
range.ensureWithin(await this.size(), `resource ${this.url}`);
|
|
125
|
-
}
|
|
126
|
-
async rememberSize() {
|
|
127
|
-
try {
|
|
128
|
-
return await this.fetchSize();
|
|
129
|
-
} catch (error) {
|
|
130
|
-
this.sizePromise = void 0;
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
async fetchSize() {
|
|
135
|
-
const response = await this.request("HEAD");
|
|
136
|
-
discardBody(response);
|
|
137
|
-
if (!response.ok) return this.sizeFromContentRange();
|
|
138
|
-
const contentLength = Number(response.headers.get("content-length"));
|
|
139
|
-
return Number.isSafeInteger(contentLength) && contentLength > 0 ? contentLength : this.sizeFromContentRange();
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Some servers omit Content-Length on HEAD or refuse HEAD; a one-byte range reveals the total.
|
|
143
|
-
*/
|
|
144
|
-
async sizeFromContentRange() {
|
|
145
|
-
const response = await this.request("GET", { Range: FIRST_BYTE_RANGE });
|
|
146
|
-
discardBody(response);
|
|
147
|
-
if (response.status !== HTTP_PARTIAL_CONTENT) throw new GyroViewError("source-unreadable", `${this.url} answered ${response.status} to a byte range; cannot determine the file size`);
|
|
148
|
-
this.hasReadRange = true;
|
|
149
|
-
const contentRange = response.headers.get("content-range");
|
|
150
|
-
if (contentRange === null && response.type === "cors") throw this.hiddenContentRange();
|
|
151
|
-
const total = CONTENT_RANGE_TOTAL.exec(contentRange ?? "")?.[1];
|
|
152
|
-
if (total === void 0) throw new GyroViewError("source-unreadable", `${this.url} reports neither Content-Length nor Content-Range; cannot determine the file size`);
|
|
153
|
-
return Number(total);
|
|
154
|
-
}
|
|
155
|
-
hiddenContentRange() {
|
|
156
|
-
return new GyroViewError("cors", `${this.url} answered a byte range but hides its Content-Range from this origin; add ${EXPOSED_HEADERS_ADVICE}`);
|
|
157
|
-
}
|
|
158
|
-
request(method, headers) {
|
|
159
|
-
return httpRequest(this.url, {
|
|
160
|
-
method,
|
|
161
|
-
...headers && { headers }
|
|
162
|
-
}, this.options);
|
|
163
|
-
}
|
|
164
36
|
};
|
|
165
|
-
/**
|
|
166
|
-
* A pause before asking again, which an abort of the source's requests ends at once.
|
|
167
|
-
*/
|
|
168
|
-
function wait(ms, signal) {
|
|
169
|
-
return new Promise((resolve, reject) => {
|
|
170
|
-
signal?.throwIfAborted();
|
|
171
|
-
const settled = new AbortController();
|
|
172
|
-
const timer = setTimeout(() => {
|
|
173
|
-
settled.abort();
|
|
174
|
-
resolve();
|
|
175
|
-
}, ms);
|
|
176
|
-
const onAbort = () => {
|
|
177
|
-
clearTimeout(timer);
|
|
178
|
-
reject(abortErrorOf(signal));
|
|
179
|
-
};
|
|
180
|
-
signal?.addEventListener("abort", onAbort, {
|
|
181
|
-
once: true,
|
|
182
|
-
signal: settled.signal
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
function passingIfUnreachable(error) {
|
|
187
|
-
return hasErrorCode(error, "source-unreadable") && error instanceof Error ? passing(new GyroViewError("source-unreadable", error.message, { cause: error.cause })) : error;
|
|
188
|
-
}
|
|
189
|
-
function abortErrorOf(signal) {
|
|
190
|
-
const reason = signal?.reason;
|
|
191
|
-
return reason instanceof Error ? reason : new DOMException("the read was aborted", "AbortError");
|
|
192
|
-
}
|
|
193
37
|
//#endregion
|
|
194
38
|
export { HttpRangeSource };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { GyroViewError, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
|
|
2
|
+
import "../../../core/src/index.js";
|
|
3
|
+
import { contentRangeOf } from "./contentRange.js";
|
|
4
|
+
import { isPassing, passing, passingIfUnreachable } from "./passingFailures.js";
|
|
5
|
+
import { EXPOSED_HEADERS_ADVICE, FIRST_BYTE_RANGE, discardBody, httpRequest, plainHttpRequest, withAbortSignal } from "./httpRequest.js";
|
|
6
|
+
import { isSameVersion, versionOf } from "./recordingVersion.js";
|
|
7
|
+
//#region ../../packages/adapters/fetch/src/HttpResource.ts
|
|
8
|
+
var HTTP_OK = 200;
|
|
9
|
+
var HTTP_PARTIAL_CONTENT = 206;
|
|
10
|
+
var HTTP_SERVER_ERROR = 500;
|
|
11
|
+
var DEFAULT_RETRY_DELAYS_MS = [250, 1e3];
|
|
12
|
+
/**
|
|
13
|
+
* One recording at a URL, as every reader of it sees it: its size, whether its server has shown
|
|
14
|
+
* it lets this page read it (CORS), the version its first answer told of (ADR 0029), and how a
|
|
15
|
+
* range that failed on the way is asked for again.
|
|
16
|
+
* The server must answer `Range` requests with 206 and, for cross-origin use, send CORS headers
|
|
17
|
+
* that expose `Content-Range`; both are hard requirements of playing a remote recording and are
|
|
18
|
+
* reported with distinct error codes.
|
|
19
|
+
*/
|
|
20
|
+
var HttpResource = class {
|
|
21
|
+
url;
|
|
22
|
+
options;
|
|
23
|
+
retryDelaysMs;
|
|
24
|
+
sizePromise;
|
|
25
|
+
/**
|
|
26
|
+
* A range has come through: CORS is proven for this resource, so a request that fails from
|
|
27
|
+
* here on failed on the way, whatever the diagnosis says of an error page without CORS headers.
|
|
28
|
+
*/
|
|
29
|
+
hasReadRange = false;
|
|
30
|
+
version;
|
|
31
|
+
/**
|
|
32
|
+
* `signal` ends every request made for the resource, with the host's own `requestInit` signal.
|
|
33
|
+
*/
|
|
34
|
+
constructor(url, options = {}, signal) {
|
|
35
|
+
this.url = url;
|
|
36
|
+
this.options = signal ? withAbortSignal(options, signal) : options;
|
|
37
|
+
this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One HEAD request (or a one-byte range where HEAD is refused or gives no length), cached for
|
|
41
|
+
* the lifetime of the resource once it succeeded; a failed lookup is retried on the next call.
|
|
42
|
+
*/
|
|
43
|
+
size() {
|
|
44
|
+
this.sizePromise ??= this.rememberSize();
|
|
45
|
+
return this.sizePromise;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Servers clamp out-of-range requests instead of failing them; the port contracts want a
|
|
49
|
+
* typed error, so the range is checked against the (cached) size first.
|
|
50
|
+
*/
|
|
51
|
+
async ensureFits(range) {
|
|
52
|
+
range.ensureWithin(await this.size(), `resource ${this.url}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Runs `attempt`, and after a short wait again, a few times, while it fails on the way: one
|
|
56
|
+
* dropped connection must not end a long playback. What asking again cannot change (an abort,
|
|
57
|
+
* a refusal, a server ignoring ranges, CORS) fails at once.
|
|
58
|
+
*/
|
|
59
|
+
async askingAgain(attempt) {
|
|
60
|
+
for (const delayMs of this.retryDelaysMs) {
|
|
61
|
+
try {
|
|
62
|
+
return await attempt();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!isPassing(error)) throw error;
|
|
65
|
+
}
|
|
66
|
+
await wait(delayMs, this.options.requestInit?.signal);
|
|
67
|
+
}
|
|
68
|
+
return attempt();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The server's answer to a byte range, its body still to be read: a 206, or the refusal it
|
|
72
|
+
* amounts to. `signal` ends this request alone.
|
|
73
|
+
*/
|
|
74
|
+
async rangeAnswer(range, signal) {
|
|
75
|
+
const options = signal ? withAbortSignal(this.options, signal) : this.options;
|
|
76
|
+
const response = await this.requestRange(range, options);
|
|
77
|
+
if (response.status !== HTTP_PARTIAL_CONTENT) {
|
|
78
|
+
discardBody(response);
|
|
79
|
+
throw this.refusalOf(response.status);
|
|
80
|
+
}
|
|
81
|
+
this.hasReadRange = true;
|
|
82
|
+
this.ensureUnchanged(response);
|
|
83
|
+
return response;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every answer is of the version the first one told of: bytes of a recording replaced while it
|
|
87
|
+
* plays must not be put together with the old ones'.
|
|
88
|
+
*/
|
|
89
|
+
ensureUnchanged(response) {
|
|
90
|
+
const seen = versionOf(response);
|
|
91
|
+
this.version ??= seen;
|
|
92
|
+
if (isSameVersion(this.version, seen)) return;
|
|
93
|
+
discardBody(response);
|
|
94
|
+
throw new GyroViewError("source-changed", `${this.url} was replaced while it played; load it again`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A request that did not get through for want of a network is worth another try; one CORS
|
|
98
|
+
* refused before any range came through is not.
|
|
99
|
+
*/
|
|
100
|
+
async requestRange(range, options) {
|
|
101
|
+
const headers = { Range: `bytes=${range.offset}-${range.end - 1}` };
|
|
102
|
+
try {
|
|
103
|
+
return this.hasReadRange ? await plainHttpRequest(this.url, {
|
|
104
|
+
method: "GET",
|
|
105
|
+
headers
|
|
106
|
+
}, options) : await httpRequest(this.url, {
|
|
107
|
+
method: "GET",
|
|
108
|
+
headers
|
|
109
|
+
}, options);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw this.hasReadRange ? this.passingOnceAnswered(error) : passingIfUnreachable(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
passingOnceAnswered(error) {
|
|
115
|
+
const message = `${this.url} could not be reached for a byte range`;
|
|
116
|
+
return isAbortError(error) ? error : passing(new GyroViewError("source-unreadable", message, { cause: error }));
|
|
117
|
+
}
|
|
118
|
+
refusalOf(status) {
|
|
119
|
+
if (status === HTTP_OK) return new GyroViewError("range-unsupported", `${this.url} ignores Range requests (answered 200 to a byte range)`);
|
|
120
|
+
const message = `${this.url} answered ${status} to a byte range`;
|
|
121
|
+
return status >= HTTP_SERVER_ERROR ? passing(new GyroViewError("source-unreadable", message)) : new GyroViewError("source-unreadable", message);
|
|
122
|
+
}
|
|
123
|
+
async rememberSize() {
|
|
124
|
+
try {
|
|
125
|
+
return await this.fetchSize();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.sizePromise = void 0;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async fetchSize() {
|
|
132
|
+
const response = await this.request("HEAD");
|
|
133
|
+
discardBody(response);
|
|
134
|
+
if (!response.ok) return this.sizeFromContentRange();
|
|
135
|
+
this.ensureUnchanged(response);
|
|
136
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
137
|
+
return Number.isSafeInteger(contentLength) && contentLength > 0 ? contentLength : this.sizeFromContentRange();
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Some servers omit Content-Length on HEAD or refuse HEAD; a one-byte range reveals the total.
|
|
141
|
+
*/
|
|
142
|
+
async sizeFromContentRange() {
|
|
143
|
+
const response = await this.request("GET", { Range: FIRST_BYTE_RANGE });
|
|
144
|
+
discardBody(response);
|
|
145
|
+
if (response.status !== HTTP_PARTIAL_CONTENT) throw new GyroViewError("source-unreadable", `${this.url} answered ${response.status} to a byte range; cannot determine the file size`);
|
|
146
|
+
this.hasReadRange = true;
|
|
147
|
+
this.ensureUnchanged(response);
|
|
148
|
+
return this.totalOf(response);
|
|
149
|
+
}
|
|
150
|
+
totalOf(response) {
|
|
151
|
+
if (!response.headers.has("content-range") && response.type === "cors") throw this.hiddenContentRange();
|
|
152
|
+
const total = contentRangeOf(response)?.total;
|
|
153
|
+
if (total === void 0) throw new GyroViewError("source-unreadable", `${this.url} reports neither Content-Length nor Content-Range; cannot determine the file size`);
|
|
154
|
+
return total;
|
|
155
|
+
}
|
|
156
|
+
hiddenContentRange() {
|
|
157
|
+
return new GyroViewError("cors", `${this.url} answered a byte range but hides its Content-Range from this origin; add ${EXPOSED_HEADERS_ADVICE}`);
|
|
158
|
+
}
|
|
159
|
+
request(method, headers) {
|
|
160
|
+
return httpRequest(this.url, {
|
|
161
|
+
method,
|
|
162
|
+
...headers && { headers }
|
|
163
|
+
}, this.options);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* A pause before asking again, which an abort of the resource's requests ends at once.
|
|
168
|
+
*/
|
|
169
|
+
function wait(ms, signal) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
const settled = new AbortController();
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
settled.abort();
|
|
175
|
+
resolve();
|
|
176
|
+
}, ms);
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
reject(abortErrorOf(signal));
|
|
180
|
+
};
|
|
181
|
+
signal?.addEventListener("abort", onAbort, {
|
|
182
|
+
once: true,
|
|
183
|
+
signal: settled.signal
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function abortErrorOf(signal) {
|
|
188
|
+
const reason = signal?.reason;
|
|
189
|
+
return reason instanceof Error ? reason : new DOMException("the read was aborted", "AbortError");
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
export { HttpResource };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region ../../packages/adapters/fetch/src/contentRange.ts
|
|
2
|
+
var CONTENT_RANGE = /^bytes (\d+)-(\d+)\/(\d+|\*)$/u;
|
|
3
|
+
var UNKNOWN_TOTAL = "*";
|
|
4
|
+
function contentRangeOf(response) {
|
|
5
|
+
const match = CONTENT_RANGE.exec(response.headers.get("content-range") ?? "");
|
|
6
|
+
if (!match) return void 0;
|
|
7
|
+
const [, first, last, total] = match;
|
|
8
|
+
return {
|
|
9
|
+
first: Number(first),
|
|
10
|
+
last: Number(last),
|
|
11
|
+
total: total === UNKNOWN_TOTAL ? void 0 : Number(total)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { contentRangeOf };
|