@runinfra/cli 0.1.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.
@@ -0,0 +1,265 @@
1
+ import { probeArtifact, requestLease } from "./catalog-api.js";
2
+ import { CliError } from "./errors.js";
3
+ import { streamRange, streamWhole } from "./http.js";
4
+ import { leaseChangeReason, leaseExpiredStatus, leaseNeedsRefresh, } from "./lease.js";
5
+ import { readProgress, renderProgress } from "./progress.js";
6
+ import { normalizeRanges, planResume, rangeLength, totalBytes, } from "./range-plan.js";
7
+ import { describeMismatch, sidecarMismatch } from "./sidecar.js";
8
+ import { sleep, unrefTimer } from "./timers.js";
9
+ const CHUNK_ATTEMPTS = 4;
10
+ const MAX_LEASE_REFRESHES = 40;
11
+ const RETRY_BASE_DELAY_MS = 500;
12
+ const PROGRESS_INTERVAL_MS = 500;
13
+ const TRANSFER_IDLE_TIMEOUT_MS = 60_000;
14
+ class LeaseHolder {
15
+ inputs;
16
+ lease;
17
+ refreshes = 0;
18
+ pending = null;
19
+ constructor(lease, inputs) {
20
+ this.inputs = inputs;
21
+ this.lease = lease;
22
+ }
23
+ async url(nowMs = Date.now()) {
24
+ if (!leaseNeedsRefresh(this.lease, nowMs))
25
+ return this.lease.url;
26
+ return (await this.renew()).url;
27
+ }
28
+ async refresh() {
29
+ return (await this.renew()).url;
30
+ }
31
+ async renew() {
32
+ if (this.pending !== null)
33
+ return await this.pending;
34
+ this.pending = this.doRenew().finally(() => {
35
+ this.pending = null;
36
+ });
37
+ return await this.pending;
38
+ }
39
+ async doRenew() {
40
+ this.refreshes += 1;
41
+ if (this.refreshes > MAX_LEASE_REFRESHES) {
42
+ throw new CliError("server_error", "The download URL had to be renewed too many times.", "Run the same command again to resume from where this left off.");
43
+ }
44
+ const lease = await requestLease(this.inputs.endpoints, this.inputs.apiKey, this.inputs.slug, this.inputs.signal);
45
+ const changed = leaseChangeReason(this.inputs.sidecar, lease);
46
+ if (changed !== null)
47
+ throw artifactChanged(this.inputs.slug, changed);
48
+ const probe = await probeArtifact(lease.url, this.inputs.signal);
49
+ if (!probe.ok) {
50
+ throw new CliError("server_error", `The renewed download URL for ${this.inputs.slug} could not be used.`, "Run the same command again to resume.");
51
+ }
52
+ const mismatch = sidecarMismatch(this.inputs.sidecar, {
53
+ slug: this.inputs.slug,
54
+ packageVersion: lease.packageVersion,
55
+ sizeBytes: probe.probe.sizeBytes,
56
+ etag: probe.probe.etag,
57
+ checksumSha256: lease.checksumSha256,
58
+ });
59
+ if (mismatch !== null)
60
+ throw artifactChanged(this.inputs.slug, describeMismatch(mismatch));
61
+ this.lease = lease;
62
+ return lease;
63
+ }
64
+ }
65
+ function artifactChanged(slug, reason) {
66
+ return new CliError("artifact_changed", `The download was stopped because ${reason}.`, `Nothing on disk was mixed between versions. Run \`runinfra pull ${slug}\` again to start the new one.`);
67
+ }
68
+ function abortedError() {
69
+ return new CliError("interrupted", "Download interrupted.", "Run the same command again to resume from the bytes already on disk.");
70
+ }
71
+ export async function runDownload(inputs) {
72
+ const { sidecar, output } = inputs;
73
+ const holder = new LeaseHolder(inputs.lease, inputs);
74
+ const committed = normalizeRanges(sidecar.committedRanges, sidecar.sizeBytes) ?? [];
75
+ let committedBytes = totalBytes(committed);
76
+ let sessionBytes = 0;
77
+ const inFlight = new Map();
78
+ const startedAtMs = Date.now();
79
+ const snapshot = () => {
80
+ let partial = 0;
81
+ for (const bytes of inFlight.values())
82
+ partial += bytes;
83
+ const totals = {
84
+ totalBytes: sidecar.sizeBytes,
85
+ completedBytes: Math.min(sidecar.sizeBytes, committedBytes + partial),
86
+ sessionBytes,
87
+ elapsedMs: Date.now() - startedAtMs,
88
+ };
89
+ output.status(` ${renderProgress(inputs.label, totals, readProgress(totals))}`);
90
+ };
91
+ const ticker = setInterval(snapshot, PROGRESS_INTERVAL_MS);
92
+ unrefTimer(ticker);
93
+ let commitChain = Promise.resolve();
94
+ const commit = async (range) => {
95
+ commitChain = commitChain.then(async () => {
96
+ await inputs.handle.datasync();
97
+ committed.push({ ...range });
98
+ const merged = normalizeRanges(committed, sidecar.sizeBytes) ?? committed;
99
+ committed.length = 0;
100
+ committed.push(...merged);
101
+ committedBytes = totalBytes(committed);
102
+ await inputs.onCommit(committed.map((entry) => ({ ...entry })));
103
+ });
104
+ await commitChain;
105
+ };
106
+ try {
107
+ if (!inputs.rangesSupported) {
108
+ await downloadSequentially(inputs, holder, (delta) => {
109
+ sessionBytes += delta;
110
+ inFlight.set(0, (inFlight.get(0) ?? 0) + delta);
111
+ });
112
+ inFlight.clear();
113
+ await commit({ start: 0, endInclusive: sidecar.sizeBytes - 1 });
114
+ snapshot();
115
+ return { sessionBytes };
116
+ }
117
+ const plan = planResume({
118
+ sizeBytes: sidecar.sizeBytes,
119
+ committed,
120
+ concurrency: inputs.concurrency,
121
+ });
122
+ const queue = plan.chunks;
123
+ let next = 0;
124
+ let failed = false;
125
+ const worker = async () => {
126
+ for (;;) {
127
+ if (failed)
128
+ return;
129
+ if (inputs.signal.aborted)
130
+ throw abortedError();
131
+ const index = next;
132
+ next += 1;
133
+ const chunk = queue[index];
134
+ if (chunk === undefined)
135
+ return;
136
+ await fetchChunk({
137
+ chunk,
138
+ index,
139
+ holder,
140
+ inputs,
141
+ onBytes: (delta) => {
142
+ sessionBytes += delta;
143
+ inFlight.set(index, (inFlight.get(index) ?? 0) + delta);
144
+ },
145
+ onReset: () => {
146
+ sessionBytes -= inFlight.get(index) ?? 0;
147
+ inFlight.set(index, 0);
148
+ },
149
+ });
150
+ inFlight.delete(index);
151
+ await commit(chunk);
152
+ }
153
+ };
154
+ const workers = Array.from({ length: Math.min(inputs.concurrency, Math.max(1, queue.length)) }, () => worker().catch((error) => {
155
+ failed = true;
156
+ throw error;
157
+ }));
158
+ const settled = await Promise.allSettled(workers);
159
+ const firstFailure = settled.find((result) => result.status === "rejected");
160
+ if (firstFailure)
161
+ throw firstFailure.reason;
162
+ snapshot();
163
+ return { sessionBytes };
164
+ }
165
+ finally {
166
+ clearInterval(ticker);
167
+ await commitChain.catch(() => undefined);
168
+ output.endStatus();
169
+ }
170
+ }
171
+ async function fetchChunk(input) {
172
+ const { chunk, holder, inputs } = input;
173
+ let lastError = null;
174
+ for (let attempt = 0; attempt < CHUNK_ATTEMPTS; attempt += 1) {
175
+ if (inputs.signal.aborted)
176
+ throw abortedError();
177
+ input.onReset();
178
+ let received = 0;
179
+ try {
180
+ const url = await holder.url();
181
+ const result = await streamRange({
182
+ url,
183
+ start: chunk.start,
184
+ endInclusive: chunk.endInclusive,
185
+ idleTimeoutMs: TRANSFER_IDLE_TIMEOUT_MS,
186
+ signal: inputs.signal,
187
+ onChunk: async (buffer) => {
188
+ const offset = received;
189
+ received += buffer.length;
190
+ await writeAt(inputs.handle, buffer, chunk.start + offset);
191
+ input.onBytes(buffer.length);
192
+ },
193
+ });
194
+ if (result.status === 206) {
195
+ if (received !== rangeLength(chunk)) {
196
+ lastError = new CliError("network", `The server sent ${received} of ${rangeLength(chunk)} bytes for one chunk.`);
197
+ }
198
+ else {
199
+ return;
200
+ }
201
+ }
202
+ else if (leaseExpiredStatus(result.status)) {
203
+ await holder.refresh();
204
+ attempt -= 1;
205
+ continue;
206
+ }
207
+ else if (result.status === 200) {
208
+ throw new CliError("ranges_unsupported", "The storage origin stopped honouring byte ranges mid-download.", "Run the same command again; it will fall back to a single stream.");
209
+ }
210
+ else {
211
+ lastError = new CliError("server_error", `Downloading one chunk failed with HTTP ${result.status}.`);
212
+ }
213
+ }
214
+ catch (error) {
215
+ if (inputs.signal.aborted)
216
+ throw abortedError();
217
+ if (error instanceof CliError &&
218
+ (error.code === "artifact_changed" || error.code === "ranges_unsupported")) {
219
+ throw error;
220
+ }
221
+ lastError = error;
222
+ }
223
+ if (attempt < CHUNK_ATTEMPTS - 1) {
224
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
225
+ }
226
+ }
227
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
228
+ const code = lastError instanceof CliError && lastError.code !== "network"
229
+ ? lastError.code
230
+ : "network";
231
+ throw new CliError(code, `A chunk of the download failed ${CHUNK_ATTEMPTS} times: ${detail}`, "Run the same command again to resume from the bytes already on disk.");
232
+ }
233
+ async function downloadSequentially(inputs, holder, onBytes) {
234
+ inputs.output.warn("This storage origin does not support byte ranges, so the download runs as a single stream and cannot be resumed.");
235
+ let written = 0;
236
+ const url = await holder.url();
237
+ const result = await streamWhole({
238
+ url,
239
+ idleTimeoutMs: TRANSFER_IDLE_TIMEOUT_MS,
240
+ signal: inputs.signal,
241
+ onChunk: async (buffer) => {
242
+ const offset = written;
243
+ written += buffer.length;
244
+ await writeAt(inputs.handle, buffer, offset);
245
+ onBytes(buffer.length);
246
+ },
247
+ });
248
+ if (result.status !== 200) {
249
+ throw new CliError("server_error", `The download failed with HTTP ${result.status}.`, "Run the command again.");
250
+ }
251
+ if (written !== inputs.sidecar.sizeBytes) {
252
+ throw new CliError("network", `The download ended early: ${written} of ${inputs.sidecar.sizeBytes} bytes.`, "Run the command again.");
253
+ }
254
+ return written;
255
+ }
256
+ async function writeAt(handle, buffer, position) {
257
+ let offset = 0;
258
+ while (offset < buffer.length) {
259
+ const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, position + offset);
260
+ if (bytesWritten <= 0) {
261
+ throw new CliError("storage_unwritable", "The filesystem stopped accepting writes for this download.", "Check free space and permissions on the destination, then run the command again.");
262
+ }
263
+ offset += bytesWritten;
264
+ }
265
+ }
@@ -0,0 +1,58 @@
1
+ export const DEFAULT_API_BASE = "https://runinfra.ai";
2
+ export const API_BASE_ENV = "RUNINFRA_API_BASE";
3
+ export function resolveApiBase(env) {
4
+ const raw = env[API_BASE_ENV]?.trim();
5
+ if (!raw)
6
+ return { ok: true, base: DEFAULT_API_BASE };
7
+ let url;
8
+ try {
9
+ url = new URL(raw);
10
+ }
11
+ catch {
12
+ return { ok: false, message: `${API_BASE_ENV} is not a valid URL: ${raw}` };
13
+ }
14
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
15
+ return {
16
+ ok: false,
17
+ message: `${API_BASE_ENV} must be an http or https URL, got ${url.protocol}`,
18
+ };
19
+ }
20
+ if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
21
+ return {
22
+ ok: false,
23
+ message: `${API_BASE_ENV} may only use http:// for a loopback host. ${url.hostname} would send your API key in plaintext.`,
24
+ };
25
+ }
26
+ if (url.search !== "" || url.hash !== "") {
27
+ return { ok: false, message: `${API_BASE_ENV} must not carry a query or fragment.` };
28
+ }
29
+ const base = `${url.origin}${url.pathname}`.replace(/\/+$/u, "");
30
+ return { ok: true, base };
31
+ }
32
+ function isLoopbackHostname(hostname) {
33
+ return (hostname === "localhost" ||
34
+ hostname === "127.0.0.1" ||
35
+ hostname === "[::1]" ||
36
+ hostname === "::1");
37
+ }
38
+ export function endpointsFor(base) {
39
+ return {
40
+ loopbackInit: `${base}/api/cli/device/loopback-init`,
41
+ consentUrl: (requestId) => `${base}/cli/authorize?request=${encodeURIComponent(requestId)}`,
42
+ authorizePage: `${base}/cli/authorize`,
43
+ token: `${base}/api/cli/token`,
44
+ deviceInit: `${base}/api/cli/device/init`,
45
+ devicePoll: `${base}/api/cli/device/poll`,
46
+ download: (slug) => `${base}/api/catalog/${encodeURIComponent(slug)}/download`,
47
+ };
48
+ }
49
+ export function sanitizeDeviceLabel(raw) {
50
+ const cleaned = raw
51
+ .replace(/[^A-Za-z0-9._~@ -]+/gu, "-")
52
+ .replace(/\s+/gu, " ")
53
+ .replace(/-{2,}/gu, "-")
54
+ .trim()
55
+ .replace(/^-+|-+$/gu, "")
56
+ .slice(0, 80);
57
+ return cleaned.length > 0 ? cleaned : null;
58
+ }
package/dist/env.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,54 @@
1
+ export const EXIT_CODES = {
2
+ usage: 2,
3
+ unsupported_runtime: 2,
4
+ not_authenticated: 3,
5
+ auth_denied: 3,
6
+ auth_expired: 3,
7
+ auth_timeout: 3,
8
+ auth_failed: 3,
9
+ not_found: 4,
10
+ not_entitled: 4,
11
+ artifact_not_ready: 4,
12
+ network: 5,
13
+ server_error: 5,
14
+ rate_limited: 5,
15
+ checksum_mismatch: 6,
16
+ artifact_changed: 6,
17
+ ranges_unsupported: 6,
18
+ disk_space: 7,
19
+ storage_unwritable: 7,
20
+ interrupted: 130,
21
+ };
22
+ export class CliError extends Error {
23
+ code;
24
+ hint;
25
+ constructor(code, message, hint = null) {
26
+ super(message);
27
+ this.name = "CliError";
28
+ this.code = code;
29
+ this.hint = hint;
30
+ }
31
+ }
32
+ export function isCliError(value) {
33
+ return value instanceof CliError;
34
+ }
35
+ export function exitCodeFor(error) {
36
+ return isCliError(error) ? EXIT_CODES[error.code] : 1;
37
+ }
38
+ export function describeError(error) {
39
+ if (isCliError(error)) {
40
+ return { code: error.code, message: error.message, hint: error.hint };
41
+ }
42
+ if (error instanceof Error) {
43
+ return {
44
+ code: "unexpected",
45
+ message: error.message || "An unexpected error occurred.",
46
+ hint: null,
47
+ };
48
+ }
49
+ return {
50
+ code: "unexpected",
51
+ message: String(error),
52
+ hint: null,
53
+ };
54
+ }
package/dist/help.js ADDED
@@ -0,0 +1,47 @@
1
+ import { API_BASE_ENV } from "./endpoints.js";
2
+ import { CONFIG_DIR_ENV } from "./paths.js";
3
+ import { CLI_NAME, CLI_VERSION } from "./version.js";
4
+ export function helpText() {
5
+ return [
6
+ `${CLI_NAME} ${CLI_VERSION}`,
7
+ "",
8
+ "Sign in from your terminal and pull the optimized model packages your",
9
+ "workspace owns.",
10
+ "",
11
+ "USAGE",
12
+ ` ${CLI_NAME} <command> [options]`,
13
+ "",
14
+ "COMMANDS",
15
+ " login [--device] Connect this terminal by approving it in a browser.",
16
+ " --device prints a code to type on another device,",
17
+ " which is what a headless GPU host needs.",
18
+ " logout Delete this machine's stored key. To end its access",
19
+ " everywhere, revoke it in Settings, API keys.",
20
+ " whoami Show the workspace and key this machine has stored,",
21
+ " and when that access expires.",
22
+ " pull <slug> Download a package this workspace owns. Resumable:",
23
+ " run the same command again to continue.",
24
+ "",
25
+ "PULL OPTIONS",
26
+ " --out DIR Where to write the file. Defaults to the current",
27
+ " directory.",
28
+ " --concurrency N Parallel connections, 1 to 16. Defaults to 8.",
29
+ "",
30
+ "ENVIRONMENT",
31
+ ` ${API_BASE_ENV} Point the CLI at another deployment. Plaintext http is`,
32
+ " accepted only for a loopback host.",
33
+ ` ${CONFIG_DIR_ENV} Where credentials are stored.`,
34
+ "",
35
+ "EXIT CODES",
36
+ " 0 success",
37
+ " 2 bad usage or unsupported runtime",
38
+ " 3 not signed in, denied, or expired",
39
+ " 4 the workspace does not own it",
40
+ " 5 network or server failure",
41
+ " 6 integrity failure (checksum, or the artifact changed mid-download)",
42
+ " 7 no space, or the destination is not writable",
43
+ " 130 interrupted",
44
+ "",
45
+ "Access can be revoked at any time from Settings, API keys on runinfra.ai.",
46
+ ].join("\n");
47
+ }
package/dist/http.js ADDED
@@ -0,0 +1,204 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { CliError } from "./errors.js";
4
+ import { userAgent } from "./version.js";
5
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
6
+ export const DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024;
7
+ const DEFAULT_MAX_REDIRECTS = 5;
8
+ function normalizeHeaders(message) {
9
+ const out = {};
10
+ for (const [name, value] of Object.entries(message.headers)) {
11
+ if (value === undefined)
12
+ continue;
13
+ out[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
14
+ }
15
+ return out;
16
+ }
17
+ function networkError(url, cause) {
18
+ const detail = cause instanceof Error ? cause.message : String(cause);
19
+ let host = url;
20
+ try {
21
+ host = new URL(url).host;
22
+ }
23
+ catch {
24
+ }
25
+ return new CliError("network", `Could not reach ${host}: ${detail}`, "Check your connection or proxy settings, then run the command again.");
26
+ }
27
+ async function dispatch(options) {
28
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
29
+ let currentUrl = options.url;
30
+ let method = options.method;
31
+ let body = options.json === undefined ? null : Buffer.from(JSON.stringify(options.json), "utf8");
32
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
33
+ const parsed = new URL(currentUrl);
34
+ const secure = parsed.protocol === "https:";
35
+ const send = secure ? httpsRequest : httpRequest;
36
+ const headers = {
37
+ accept: "application/json",
38
+ "user-agent": userAgent(),
39
+ ...(options.headers ?? {}),
40
+ };
41
+ if (body) {
42
+ headers["content-type"] = "application/json";
43
+ headers["content-length"] = String(body.length);
44
+ }
45
+ const message = await new Promise((resolve, reject) => {
46
+ const req = send({
47
+ protocol: parsed.protocol,
48
+ hostname: parsed.hostname,
49
+ port: parsed.port,
50
+ path: `${parsed.pathname}${parsed.search}`,
51
+ method,
52
+ headers,
53
+ ...(options.signal ? { signal: options.signal } : {}),
54
+ }, resolve);
55
+ req.setTimeout(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, () => {
56
+ req.destroy(new Error("timed out waiting for the server"));
57
+ });
58
+ req.on("error", reject);
59
+ if (body)
60
+ req.write(body);
61
+ req.end();
62
+ }).catch((cause) => {
63
+ throw networkError(currentUrl, cause);
64
+ });
65
+ const status = message.statusCode ?? 0;
66
+ const location = message.headers.location;
67
+ const isRedirect = (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) &&
68
+ typeof location === "string";
69
+ if (!isRedirect) {
70
+ return { message, finalUrl: currentUrl };
71
+ }
72
+ message.resume();
73
+ const next = new URL(location, currentUrl);
74
+ if (parsed.protocol === "https:" && next.protocol !== "https:") {
75
+ throw new CliError("network", `${parsed.host} redirected to an insecure URL (${next.protocol}//${next.host}).`, "This is not something the CLI can safely follow. Report it to support.");
76
+ }
77
+ if (status === 303 || ((status === 301 || status === 302) && method !== "HEAD")) {
78
+ method = "GET";
79
+ body = null;
80
+ }
81
+ currentUrl = next.toString();
82
+ }
83
+ throw new CliError("network", `Too many redirects while requesting ${options.url}.`, null);
84
+ }
85
+ async function bufferBody(message, url, maxBytes) {
86
+ return await new Promise((resolve, reject) => {
87
+ const chunks = [];
88
+ let total = 0;
89
+ message.on("data", (chunk) => {
90
+ total += chunk.length;
91
+ if (total > maxBytes) {
92
+ message.destroy();
93
+ reject(new CliError("server_error", `${url} returned an unexpectedly large response.`));
94
+ return;
95
+ }
96
+ chunks.push(chunk);
97
+ });
98
+ message.on("end", () => resolve(Buffer.concat(chunks)));
99
+ message.on("error", (cause) => reject(networkError(url, cause)));
100
+ });
101
+ }
102
+ export async function requestRaw(options) {
103
+ const { message, finalUrl } = await dispatch(options);
104
+ const body = await bufferBody(message, finalUrl, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
105
+ return { status: message.statusCode ?? 0, headers: normalizeHeaders(message), body };
106
+ }
107
+ export async function requestJson(options) {
108
+ const raw = await requestRaw(options);
109
+ if (raw.body.length === 0) {
110
+ return { status: raw.status, headers: raw.headers, body: null };
111
+ }
112
+ try {
113
+ return {
114
+ status: raw.status,
115
+ headers: raw.headers,
116
+ body: JSON.parse(raw.body.toString("utf8")),
117
+ };
118
+ }
119
+ catch {
120
+ return { status: raw.status, headers: raw.headers, body: null };
121
+ }
122
+ }
123
+ export async function headResource(url, options = {}) {
124
+ const { message } = await dispatch({
125
+ method: "HEAD",
126
+ url,
127
+ ...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
128
+ ...(options.signal ? { signal: options.signal } : {}),
129
+ });
130
+ message.resume();
131
+ return { status: message.statusCode ?? 0, headers: normalizeHeaders(message) };
132
+ }
133
+ export async function streamRange(request) {
134
+ const { message, finalUrl } = await dispatch({
135
+ method: "GET",
136
+ url: request.url,
137
+ headers: { range: `bytes=${request.start}-${request.endInclusive}` },
138
+ ...(request.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: request.idleTimeoutMs }),
139
+ ...(request.signal ? { signal: request.signal } : {}),
140
+ });
141
+ const status = message.statusCode ?? 0;
142
+ const headers = normalizeHeaders(message);
143
+ if (status !== 206) {
144
+ await drain(message);
145
+ return { status, headers, bytes: 0 };
146
+ }
147
+ const bytes = await consumeBody(message, finalUrl, request.onChunk);
148
+ return { status, headers, bytes };
149
+ }
150
+ export async function streamWhole(request) {
151
+ const { message, finalUrl } = await dispatch({
152
+ method: "GET",
153
+ url: request.url,
154
+ ...(request.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: request.idleTimeoutMs }),
155
+ ...(request.signal ? { signal: request.signal } : {}),
156
+ });
157
+ const status = message.statusCode ?? 0;
158
+ const headers = normalizeHeaders(message);
159
+ if (status !== 200) {
160
+ await drain(message);
161
+ return { status, headers, bytes: 0 };
162
+ }
163
+ const bytes = await consumeBody(message, finalUrl, request.onChunk);
164
+ return { status, headers, bytes };
165
+ }
166
+ async function drain(message) {
167
+ message.resume();
168
+ await new Promise((resolve) => {
169
+ message.on("end", resolve);
170
+ message.on("close", resolve);
171
+ message.on("error", () => resolve());
172
+ });
173
+ }
174
+ async function consumeBody(message, finalUrl, onChunk) {
175
+ let bytes = 0;
176
+ await new Promise((resolve, reject) => {
177
+ let failed = false;
178
+ let pendingWrite = Promise.resolve();
179
+ const fail = (cause) => {
180
+ if (failed)
181
+ return;
182
+ failed = true;
183
+ message.destroy();
184
+ reject(cause instanceof CliError ? cause : networkError(finalUrl, cause));
185
+ };
186
+ message.on("data", (chunk) => {
187
+ bytes += chunk.length;
188
+ const outcome = onChunk(chunk);
189
+ if (outcome instanceof Promise) {
190
+ message.pause();
191
+ pendingWrite = outcome;
192
+ outcome.then(() => message.resume(), (cause) => fail(cause));
193
+ }
194
+ });
195
+ message.on("end", () => {
196
+ pendingWrite.then(() => {
197
+ if (!failed)
198
+ resolve();
199
+ }, fail);
200
+ });
201
+ message.on("error", fail);
202
+ });
203
+ return bytes;
204
+ }