File size: 4,774 Bytes
0865492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
/**
 * WebBlob is a Blob implementation for web resources that supports range requests.
 */

import { createApiError } from "../error";

interface WebBlobCreateOptions {
	/**
	 * @default 1_000_000
	 *
	 * Objects below that size will immediately be fetched and put in RAM, rather
	 * than streamed ad-hoc
	 */
	cacheBelow?: number;
	/**
	 * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
	 */
	fetch?: typeof fetch;
	accessToken: string | undefined;
}

export class WebBlob extends Blob {
	static async create(url: URL, opts?: WebBlobCreateOptions): Promise<Blob> {
		const customFetch = opts?.fetch ?? fetch;

		// Probe with `Range: bytes=0-0` rather than `HEAD` to learn the file size
		// and confirm range support in a single round trip.
		//
		// In browsers, when CloudFront gzips a response on the fly (typical for
		// small text/JSON files behind `/api/resolve-cache/...`), the cached
		// response loses both `Content-Length` and `Accept-Ranges`. Subsequent
		// HEAD requests served from that cache inherit the missing headers, so
		// the lib could not tell either the file size or whether ranges were
		// supported, and silently fell back to buffering the whole blob in RAM.
		//
		// Range responses are never content-encoded, so `Content-Range`
		// (carrying the total size) and the strong `ETag` always survive,
		// regardless of the cached encoding state.
		const probe = await customFetch(url, {
			headers: {
				Range: "bytes=0-0",
				...(opts?.accessToken && { Authorization: `Bearer ${opts.accessToken}` }),
			},
		});

		if (!probe.ok) {
			throw await createApiError(probe);
		}

		const contentType = probe.headers.get("content-type") || "";

		// 206 → server honored the range request; total size is in `Content-Range`.
		if (probe.status === 206) {
			const totalSize = Number(probe.headers.get("content-range")?.split("/").pop());
			await probe.body?.cancel();

			if (Number.isFinite(totalSize) && totalSize >= (opts?.cacheBelow ?? 1_000_000)) {
				return new WebBlob(url, 0, totalSize, contentType, true, customFetch, opts?.accessToken);
			}

			// Small file (or unknown total) → buffer it in RAM.
			const full = await customFetch(url, {
				...(opts?.accessToken && { headers: { Authorization: `Bearer ${opts.accessToken}` } }),
			});
			if (!full.ok) {
				throw await createApiError(full);
			}
			return full.blob();
		}

		// 200 → server ignored `Range`; we've already started downloading the
		// full body, so just consume it.
		return probe.blob();
	}

	private url: URL;
	private start: number;
	private end: number;
	private contentType: string;
	private full: boolean;
	private fetch: typeof fetch;
	private accessToken: string | undefined;

	constructor(
		url: URL,
		start: number,
		end: number,
		contentType: string,
		full: boolean,
		customFetch: typeof fetch,
		accessToken: string | undefined,
	) {
		super([]);

		this.url = url;
		this.start = start;
		this.end = end;
		this.contentType = contentType;
		this.full = full;
		this.fetch = customFetch;
		this.accessToken = accessToken;
	}

	override get size(): number {
		return this.end - this.start;
	}

	override get type(): string {
		return this.contentType;
	}

	override slice(start = 0, end = this.size): WebBlob {
		if (start < 0 || end < 0) {
			new TypeError("Unsupported negative start/end on WebBlob.slice");
		}

		const slice = new WebBlob(
			this.url,
			this.start + start,
			Math.min(this.start + end, this.end),
			this.contentType,
			start === 0 && end === this.size ? this.full : false,
			this.fetch,
			this.accessToken,
		);

		return slice;
	}

	override async arrayBuffer(): Promise<ArrayBuffer> {
		const result = await this.fetchRange();

		return result.arrayBuffer();
	}

	override async text(): Promise<string> {
		const result = await this.fetchRange();

		return result.text();
	}

	override stream(): ReturnType<Blob["stream"]> {
		const stream = new TransformStream();

		this.fetchRange()
			.then((response) => response.body?.pipeThrough(stream))
			.catch((error) => stream.writable.abort(error.message));

		return stream.readable;
	}

	private fetchRange(): Promise<Response> {
		const fetch = this.fetch; // to avoid this.fetch() which is bound to the instance instead of globalThis
		if (this.full) {
			return fetch(this.url, {
				...(this.accessToken && {
					headers: {
						Authorization: `Bearer ${this.accessToken}`,
					},
				}),
			}).then((resp) => (resp.ok ? resp : createApiError(resp)));
		}
		return fetch(this.url, {
			headers: {
				Range: `bytes=${this.start}-${this.end - 1}`,
				...(this.accessToken && { Authorization: `Bearer ${this.accessToken}` }),
			},
		}).then((resp) => (resp.ok ? resp : createApiError(resp)));
	}
}