diff --git a/integrations/code/package-lock.json b/integrations/code/package-lock.json index 8cd34b4..814e873 100644 --- a/integrations/code/package-lock.json +++ b/integrations/code/package-lock.json @@ -1,12 +1,12 @@ { "name": "zensical-studio", - "version": "0.2.17", + "version": "0.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zensical-studio", - "version": "0.2.17", + "version": "0.2.18", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.26", diff --git a/integrations/code/package.json b/integrations/code/package.json index 4c61a41..3b33dfa 100644 --- a/integrations/code/package.json +++ b/integrations/code/package.json @@ -1,6 +1,6 @@ { "name": "zensical-studio", - "version": "0.2.17", + "version": "0.2.18", "publisher": "zensical", "displayName": "Zensical Studio", "description": "Refactor documentation like code", diff --git a/integrations/code/src/extension.ts b/integrations/code/src/extension.ts index 986427e..708cde7 100644 --- a/integrations/code/src/extension.ts +++ b/integrations/code/src/extension.ts @@ -34,9 +34,16 @@ import { ConnectionsView } from "./connections"; import { createLanguageClient } from "./extension/client"; import { Context } from "./extension/context"; import { activateProjectMarkdown } from "./extension/project"; +import { + type RecoveryFailureKind, + StudioRecovery, +} from "./extension/recovery"; import { getStudio } from "./extension/studio"; import type { Studio } from "./extension/studio"; -import { NetworkError } from "./extension/studio/fetch"; +import { + ArchiveTransferError, + NetworkError, +} from "./extension/studio/fetch"; import { WordCount } from "./word-count"; /* ---------------------------------------------------------------------------- @@ -74,39 +81,9 @@ let connections: ConnectionsView | undefined; let retryTimer: ReturnType | undefined; /** - * Whether editor activity may bring the current retry forward. + * Studio recovery state machine. */ -let retryOnActivity = false; - -/** - * Whether Studio is recovering from an unexpected server stop. - */ -let recovering = false; - -/** - * Number of consecutive runtime availability failures. - */ -let availabilityFailures = 0; - -/** - * Whether the current availability failure has been reported. - */ -let availabilityNoticeShown = false; - -/** - * Number of consecutive process startup or runtime failures. - */ -let startupFailures = 0; - -/** - * Whether the current startup failure has been reported. - */ -let startupNoticeShown = false; - -/** - * Startup retry delay. - */ -let retryDelay = 5000; +const recovery = new StudioRecovery(); /** * Timer that resets restart backoff after a stable session. @@ -134,6 +111,7 @@ const pending = new Map(); */ export async function activate(extension: ExtensionContext): Promise { const context = new Context(extension); + recovery.reset(); wordCount = new WordCount(() => client); extension.subscriptions.push(wordCount); connections = new ConnectionsView(extension, () => client); @@ -150,24 +128,16 @@ export async function activate(extension: ExtensionContext): Promise { // retry more responsive when the user returns to the window or opens a // Python Markdown document after VPN/proxy startup has completed. vscode.window.onDidChangeWindowState((state) => { - if ( - state.focused && - typeof retryTimer !== "undefined" && - retryOnActivity - ) { - void startStudio(extension, context); + if (state.focused) { + retryStudioOnActivity(extension, context); } }), vscode.workspace.onDidOpenTextDocument((document) => { if (document.languageId === "markdown") { pending.set(document.uri.toString(), document); } - if ( - document.languageId === "python-markdown" && - typeof retryTimer !== "undefined" && - retryOnActivity - ) { - void startStudio(extension, context); + if (document.languageId === "python-markdown") { + retryStudioOnActivity(extension, context); } }), ); @@ -190,6 +160,7 @@ export async function activate(extension: ExtensionContext): Promise { export async function deactivate(): Promise { clearRetry(); clearRetryReset(); + recovery.reset(); disposeClientDisposables(); const previous = client; client = undefined; @@ -224,12 +195,12 @@ async function startStudio( // Resolve once, then reuse the same runtime throughout this retry episode. studio ??= await getStudio(context); if (typeof studio === "undefined") { - recordAvailabilityFailure(context); - scheduleRetry(extension, context, "Studio unavailable", true); + recoverStudioFailure( + extension, context, "availability", "Studio unavailable", + ); return; } - availabilityFailures = 0; - availabilityNoticeShown = false; + recovery.resolved(); // Create and start the language client context.log("Starting Zensical Studio"); @@ -264,17 +235,19 @@ async function startStudio( // Log the error const message = error instanceof Error ? error.message : String(error); context.log(`Failed to start Zensical Studio: ${message}`); - if (error instanceof NetworkError) { - recordAvailabilityFailure(context); + if (error instanceof ArchiveTransferError) { + recoverStudioFailure( + extension, context, "download", "Studio download failed", error, + ); + } else if (error instanceof NetworkError) { + recoverStudioFailure( + extension, context, "availability", "Network unavailable", + ); } else { - recordStartupFailure(context, message); + recoverStudioFailure( + extension, context, "startup", "Startup failed", message, + ); } - scheduleRetry( - extension, - context, - error instanceof NetworkError ? "Network unavailable" : "Startup failed", - error instanceof NetworkError, - ); } finally { starting = false; } @@ -291,10 +264,8 @@ async function restartStudio( ): Promise { clearRetry(); clearRetryReset(); - retryDelay = 5000; - recovering = false; + recovery.reset(); studio = undefined; - resetFailureNotices(); const previous = client; if (typeof previous === "undefined") { await startStudio(extension, context); @@ -334,17 +305,17 @@ async function recoverStudio( const serverProcess = stopped.serverProcess; const reason = describeServerStop(serverProcess); context.log(`Studio stopped unexpectedly: ${reason}`); - recordStartupFailure(context, reason); client = undefined; clearRetryReset(); disposeClientDisposables(); stopped.dispose(); await terminateServerProcess(serverProcess, context); - if (!recovering) { - recovering = true; + if (recovery.beginRecovery()) { studio = undefined; } - scheduleRetry(extension, context, "Studio stopped"); + recoverStudioFailure( + extension, context, "startup", "Studio stopped", reason, + ); } /** @@ -400,91 +371,144 @@ function waitForProcessExit( } /** - * Schedule startup retry. + * Recover from a Studio failure according to its policy. * * @param extension - Extension context * @param context - Context - * @param reason - Retry reason shown in the output channel - * @param onActivity - Whether editor activity may bring the retry forward + * @param kind - Failure kind + * @param reason - Failure reason shown in the output channel + * @param detail - Failure detail used for logging and diagnostics */ -function scheduleRetry( - extension: ExtensionContext, context: Context, reason: string, - onActivity = false, +function recoverStudioFailure( + extension: ExtensionContext, context: Context, + kind: RecoveryFailureKind, reason: string, + detail?: string | ArchiveTransferError, ): void { - if (typeof retryTimer !== "undefined") { + clearRetryReset(); + const decision = recovery.fail(kind); + logStudioFailure(context, kind, decision.attempt, detail); + if (decision.notify) { + reportStudioFailure(context, kind, decision.attempt, detail); + } + if (!decision.retry) { + context.log( + `${reason}; automatic retries stopped after ${decision.attempt} attempts`, + ); return; } - - clearRetryReset(); - if (!onActivity && startupFailures >= 3) { - retryOnActivity = false; - context.log(`${reason}; automatic retries stopped after 3 attempts`); + if (typeof retryTimer !== "undefined") { return; } - retryOnActivity = onActivity; - const delay = retryDelay; - const seconds = Math.round(delay / 1000); + const seconds = Math.round(decision.delay / 1000); context.log(`${reason}; retrying in ${seconds}s`); // Schedule retry with exponential backoff and jitter - retryTimer = setTimeout(() => { + const retry = () => { retryTimer = undefined; if (starting) { - scheduleRetry(extension, context, reason); + retryTimer = setTimeout(retry, jitter(decision.delay)); return; } + if (!recovery.retry()) return; void startStudio(extension, context); - }, jitter(delay)); - retryDelay = Math.min(delay * 2, 5 * 60 * 1000); + }; + retryTimer = setTimeout(retry, jitter(decision.delay)); } /** - * Reset restart backoff after the current client remains stable. + * Retry Studio early in response to editor activity. + * + * @param extension - Extension context + * @param context - Context */ -function markStudioStable(): void { - clearRetryReset(); - retryResetTimer = setTimeout(() => { - retryResetTimer = undefined; - retryDelay = 5000; - recovering = false; - resetFailureNotices(); - }, 3 * 60 * 1000); +function retryStudioOnActivity( + extension: ExtensionContext, context: Context, +): void { + if ( + starting || + typeof retryTimer === "undefined" || + !recovery.retry(true) + ) { + return; + } + clearTimeout(retryTimer); + retryTimer = undefined; + void startStudio(extension, context); } /** - * Record that Studio's runtime could not be resolved. + * Log a Studio failure attempt. * * @param context - Context + * @param kind - Failure kind + * @param attempt - Failure attempt + * @param detail - Failure detail */ -function recordAvailabilityFailure(context: Context): void { - availabilityFailures += 1; - if (availabilityFailures < 3 || availabilityNoticeShown) { - return; +function logStudioFailure( + context: Context, kind: RecoveryFailureKind, attempt: number, + detail?: string | ArchiveTransferError, +): void { + switch (kind) { + case "availability": + context.log(`Availability attempt ${attempt} failed`); + break; + case "download": + context.log( + `Download attempt ${attempt} failed: ${singleLine( + detail instanceof Error ? detail.message : String(detail), + )}`, + ); + break; + case "startup": + context.log( + `Startup attempt ${attempt} failed: ${singleLine(String(detail))}`, + ); + break; } - - availabilityNoticeShown = true; - logStartupEnvironment(context); - void context.promptStudioUnavailable(); } /** - * Record that Studio failed to start or stopped unexpectedly. + * Report a repeated Studio failure to the author. * * @param context - Context - * @param reason - Failure reason + * @param kind - Failure kind + * @param attempt - Failed attempt + * @param detail - Failure detail */ -function recordStartupFailure(context: Context, reason: string): void { - startupFailures += 1; - context.log( - `Startup attempt ${startupFailures} failed: ${singleLine(reason)}`, - ); - if (startupFailures < 3 || startupNoticeShown) { - return; +function reportStudioFailure( + context: Context, kind: RecoveryFailureKind, attempt: number, + detail?: string | ArchiveTransferError, +): void { + switch (kind) { + case "availability": + logStartupEnvironment(context); + void context.promptStudioUnavailable(); + break; + case "download": + if (detail instanceof ArchiveTransferError) { + const diagnostics = downloadDiagnostics( + context, detail, attempt, + ); + context.log(`Download diagnostics:\n${diagnostics}`); + void context.promptStudioDownloadFailure(diagnostics); + } + break; + case "startup": + logStartupEnvironment(context); + void context.promptStudioStartupFailure(); + break; } +} - startupNoticeShown = true; - logStartupEnvironment(context); - void context.promptStudioStartupFailure(); +/** + * Reset restart backoff after the current client remains stable. + */ +function markStudioStable(): void { + clearRetryReset(); + retryResetTimer = setTimeout(() => { + retryResetTimer = undefined; + recovery.reset(); + }, 3 * 60 * 1000); } /** @@ -508,6 +532,67 @@ function logStartupEnvironment(context: Context): void { ); } +/** + * Collect safe diagnostics for a Studio archive transfer failure. + * + * @param context - Context + * @param error - Archive transfer error + * @param attempt - Failed download attempt + * + * @returns Diagnostics + */ +function downloadDiagnostics( + context: Context, error: ArchiveTransferError, attempt: number, +): string { + const http = vscode.workspace.getConfiguration("http"); + const proxy = http.get("proxy")?.trim(); + const noProxy = http.get("noProxy") ?? []; + const remote = vscode.env.remoteName ?? "local"; + const installed = context.getState("version") ?? "none"; + return [ + "Zensical Studio download diagnostics", + `Timestamp: ${new Date().toISOString()}`, + `Attempt: ${attempt}`, + `Extension: ${context.getVersion()}`, + `Installed Studio: ${installed}`, + `Editor: ${vscode.env.appName} ${vscode.version}`, + `Platform: ${process.platform} ${getOsRelease()}/${process.arch}`, + `Remote: ${remote}`, + `Configured proxy: ${Boolean(proxy)}`, + `No-proxy entries: ${noProxy.length}`, + `Proxy support: ${http.get("proxySupport", "unknown")}`, + `Proxy strict SSL: ${http.get("proxyStrictSSL", "unknown")}`, + `Fetch support: ${http.get("fetchAdditionalSupport", "unknown")}`, + `Electron fetch: ${http.get("electronFetch", "unknown")}`, + `System certificates: ${http.get("systemCertificates", "unknown")}`, + `Node system certificates: ${http.get( + "systemCertificatesNode", "unknown", + )}`, + `Local proxy configuration: ${http.get( + "useLocalProxyConfiguration", "unknown", + )}`, + `HTTP proxy environment: ${environmentVariablePresent( + "HTTP_PROXY", "http_proxy", + )}`, + `HTTPS proxy environment: ${environmentVariablePresent( + "HTTPS_PROXY", "https_proxy", + )}`, + "", + error.diagnostics, + ].join("\n"); +} + +/** + * Check whether any named environment variable has a non-empty value. + * + * @param names - Environment variable names + * + * @returns Whether a variable is present + */ +function environmentVariablePresent(...names: string[]): boolean { + return names.some((name) => Boolean(process.env[name])); +} + /** * Describe how the server process stopped. * @@ -528,16 +613,6 @@ function describeServerStop(serverProcess: ChildProcess | undefined): string { return "language server connection closed while the process was running"; } -/** - * Reset failure counters after a manual restart or stable session. - */ -function resetFailureNotices(): void { - availabilityFailures = 0; - availabilityNoticeShown = false; - startupFailures = 0; - startupNoticeShown = false; -} - /** * Collapse a failure reason into one log line. * @@ -557,7 +632,6 @@ function clearRetry(): void { clearTimeout(retryTimer); retryTimer = undefined; } - retryOnActivity = false; } /** diff --git a/integrations/code/src/extension/context.ts b/integrations/code/src/extension/context.ts index cf9641e..4fc1db2 100644 --- a/integrations/code/src/extension/context.ts +++ b/integrations/code/src/extension/context.ts @@ -188,6 +188,50 @@ export class Context { } } + /** + * Explain a repeated Studio download failure and offer reporting actions. + * + * @param diagnostics - Diagnostics safe to include in an issue report + */ + public async promptStudioDownloadFailure( + diagnostics: string, + ): Promise { + const report = "Report Issue"; + const copy = "Copy Diagnostics"; + const logs = "Show Logs"; + const retry = "Retry"; + const result = await vscode.window.showErrorMessage( + "Zensical Studio could not be downloaded after several attempts. " + + "Automatic retries have stopped. Please report this issue so we can " + + "investigate the failed transfer.", + report, + copy, + logs, + retry, + ); + if (result === report) { + await vscode.env.clipboard.writeText(diagnostics); + this.output.show(); + void vscode.window.showInformationMessage( + "Studio download diagnostics copied to the clipboard.", + ); + await vscode.env.openExternal(vscode.Uri.parse( + "https://github.com/zensical/studio/issues/new" + + "?template=01-report-a-bug.yml" + + "&title=Studio%20download%20fails", + )); + } else if (result === copy) { + await vscode.env.clipboard.writeText(diagnostics); + void vscode.window.showInformationMessage( + "Studio download diagnostics copied to the clipboard.", + ); + } else if (result === logs) { + this.output.show(); + } else if (result === retry) { + await vscode.commands.executeCommand("zensicalStudio.restartServer"); + } + } + /** * Prompt the user to update the extension. * diff --git a/integrations/code/src/extension/recovery.ts b/integrations/code/src/extension/recovery.ts new file mode 100644 index 0000000..d60eb30 --- /dev/null +++ b/integrations/code/src/extension/recovery.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Zensical and contributors + * + * SPDX-License-Identifier: MIT + * All contributions are certified under the DCO + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Recoverable Studio failure. + */ +export type RecoveryFailureKind = "availability" | "download" | "startup"; + +/* ---------------------------------------------------------------------------- + * Constants + * ------------------------------------------------------------------------- */ + +/** + * Number of failures before notifying the author or stopping bounded retries. + */ +const FAILURE_LIMIT = 3; + +/** + * Initial and maximum retry delays. + */ +const RETRY_DELAY = 5000; +const RETRY_DELAY_MAX = 5 * 60 * 1000; + +/* ---------------------------------------------------------------------------- + * Class + * ------------------------------------------------------------------------- */ + +/** + * Studio recovery state. + */ +export class StudioRecovery { + /** Failure attempts by kind. */ + private attempts = failureRecord(); + /** Failure awaiting a retry. */ + private pending: RecoveryFailureKind | undefined; + /** Delay for the next retry. */ + private delay = RETRY_DELAY; + /** Whether Studio is recovering from an unexpected process stop. */ + private recovering = false; + + /** + * Record a failure and decide how recovery should continue. + * + * @param kind - Failure kind + * + * @returns Recovery decision + */ + public fail(kind: RecoveryFailureKind) { + const attempt = this.attempts[kind] + 1; + const retry = kind === "availability" || attempt < FAILURE_LIMIT; + const delay = this.delay; + this.attempts[kind] = attempt; + this.pending = retry ? kind : undefined; + if (retry) { + this.delay = Math.min(delay * 2, RETRY_DELAY_MAX); + } + return { + attempt, + delay, + notify: attempt === FAILURE_LIMIT, + retry, + }; + } + + /** + * Begin a scheduled or activity-triggered retry. + * + * @param onActivity - Whether editor activity triggered the retry + * + * @returns Whether recovery may retry + */ + public retry(onActivity = false): boolean { + if ( + typeof this.pending === "undefined" || + (onActivity && this.pending !== "availability") + ) { + return false; + } + this.pending = undefined; + return true; + } + + /** + * Record successful runtime resolution. + */ + public resolved(): void { + this.attempts.availability = 0; + this.attempts.download = 0; + if (this.pending === "availability" || this.pending === "download") { + this.pending = undefined; + } + } + + /** + * Begin recovery from an unexpected process stop. + * + * @returns Whether the runtime should be resolved again + */ + public beginRecovery(): boolean { + if (this.recovering) { + return false; + } + this.recovering = true; + return true; + } + + /** + * Reset recovery after a manual restart or stable session. + */ + public reset(): void { + this.attempts = failureRecord(); + this.pending = undefined; + this.delay = RETRY_DELAY; + this.recovering = false; + } +} + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Create empty failure counters. + * + * @returns Failure counters + */ +function failureRecord(): Record { + return { + availability: 0, + download: 0, + startup: 0, + }; +} diff --git a/integrations/code/src/extension/studio/fetch.ts b/integrations/code/src/extension/studio/fetch.ts index bf1f9d8..671a6c3 100644 --- a/integrations/code/src/extension/studio/fetch.ts +++ b/integrations/code/src/extension/studio/fetch.ts @@ -81,6 +81,29 @@ export class NetworkError extends Error { } } +/** + * Archive transfer failure. + */ +export class ArchiveTransferError extends Error { + /** Transfer diagnostics safe to include in an issue report. */ + public readonly diagnostics: string; + + /** + * Create error. + * + * @param message - Message + * @param diagnostics - Transfer diagnostics + * @param cause - Underlying error + */ + public constructor( + message: string, diagnostics: string, cause?: unknown, + ) { + super(message, { cause }); + this.name = "ArchiveTransferError"; + this.diagnostics = diagnostics; + } +} + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -134,17 +157,59 @@ export async function fetchArchive( context.log(`Fetching Zensical Studio ${release.version}`); // Fetch the archive for the given release - const res = await request(context, release.url); - if (typeof res !== "undefined") { - try { - return new Uint8Array(await res.arrayBuffer()); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new NetworkError(`Archive transfer failed: ${reason}`); + const started = Date.now(); + let res: Response; + try { + res = await fetchResponse(context, release.url); + } catch (error) { + throw archiveTransferError(release, started, 0, undefined, error); + } + if (!res.ok) { + throw archiveTransferError(release, started, 0, res); + } + if (!isArchiveContentType(res.headers.get("content-type"))) { + throw archiveTransferError( + release, started, 0, res, + new Error("Unexpected archive content type"), + ); + } + + // Read the response as a stream so interrupted transfers retain their + // received byte count in diagnostics. + const chunks: Uint8Array[] = []; + let received = 0; + try { + if (res.body === null) { + throw new Error("Archive response has no body"); } - } else { - throw new NetworkError("Archive request failed; see HTTP status in logs"); + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.byteLength; + } + } catch (error) { + throw archiveTransferError(release, started, received, res, error); + } + + // Check that the received byte count matches the expected content length + const expected = contentLength(res); + if (typeof expected !== "undefined" && received !== expected) { + throw archiveTransferError( + release, started, received, res, + new Error(`Received ${received} of ${expected} bytes`), + ); + } + + // Combine the received chunks into a single byte array and return it + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; } + return bytes; } /** @@ -197,29 +262,177 @@ async function request( url: string, init?: RequestInit, ): Promise { + const res = await fetchResponse(context, url, init); + + // In case of a non-OK response, log the error and return nothing + if (!res.ok) { + context.log(`Fetching failed: ${res.status} ${res.statusText}`); + return; + } else { + return res; + } +} + +/** + * Fetch a resource and preserve non-OK responses for callers that need them. + * + * @param context - Context + * @param url - Resource URL + * @param init - Request initialization + * + * @returns Response + */ +async function fetchResponse( + context: Context, + url: string, + init?: RequestInit, +): Promise { try { - const res = await fetch(url, { + return await fetch(url, { ...init, headers: { ...init?.headers, "x-zensical-studio-version": context.getVersion(), }, }); - - // In case of a non-OK response, log the error and return nothing - if (!res.ok) { - context.log(`Fetching failed: ${res.status} ${res.statusText}`); - return; - } else { - return res; - } } catch (error) { - const reason = error instanceof Error ? error.message : String(error); + const reason = describeError(error); context.log(`Fetching failed: ${reason}`); throw new NetworkError(reason); } } +/** + * Create an archive transfer error with safe diagnostics. + * + * @param release - Release + * @param started - Transfer start time + * @param received - Number of received bytes + * @param response - Response, if headers were received + * @param cause - Underlying error + * + * @returns Archive transfer error + */ +function archiveTransferError( + release: Release, started: number, received: number, + response?: Response, cause?: unknown, +): ArchiveTransferError { + const expected = response === undefined ? undefined : contentLength(response); + const reason = cause === undefined + ? `HTTP ${response?.status ?? "unknown"} ${response?.statusText ?? ""}`.trim() + : describeError(cause); + const diagnostics = [ + `Release: ${release.version}`, + `Status: ${response?.status ?? "unavailable"}`, + `Final URL: ${safeUrl(response?.url || release.url)}`, + `Redirected: ${response?.redirected ?? false}`, + `Content-Type: ${response?.headers.get("content-type") ?? "unavailable"}`, + `Content-Length: ${expected ?? "unavailable"}`, + `Content-Encoding: ${response?.headers.get("content-encoding") ?? "none"}`, + `ETag: ${response?.headers.get("etag") ?? "unavailable"}`, + `CF-Ray: ${response?.headers.get("cf-ray") ?? "unavailable"}`, + `Received bytes: ${received}`, + `Elapsed milliseconds: ${Date.now() - started}`, + `Error: ${reason}`, + ].join("\n"); + return new ArchiveTransferError( + `Archive transfer failed: ${reason}`, diagnostics, cause, + ); +} + +/** + * Remove credentials, query parameters, and fragments from a diagnostic URL. + * + * @param value - URL + * + * @returns Safe URL + */ +function safeUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return "unavailable"; + } +} + +/** + * Check whether a response contains the expected archive media type. + * + * @param value - Content type + * + * @returns Whether the content type is valid + */ +function isArchiveContentType(value: string | null): boolean { + const type = value?.split(";", 1)[0].trim().toLowerCase(); + return type === "application/octet-stream"; +} + +/** + * Read a valid content length from a response. + * + * @param response - Response + * + * @returns Content length or nothing + */ +function contentLength(response: Response): number | undefined { + const value = response.headers.get("content-length"); + if (value === null || !/^\d+$/.test(value)) return; + const length = Number(value); + return Number.isSafeInteger(length) ? length : undefined; +} + +/** + * Describe an error and its immediate cause. + * + * @param error - Error + * + * @returns Description + */ +function describeError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const details = [error.name, sanitizeText(error.message)]; + const code = errorCode(error); + if (typeof code !== "undefined") details.push(`code=${code}`); + if (error.cause instanceof Error) { + const cause = error.cause; + const causeCode = errorCode(cause); + details.push( + `cause=${cause.name}: ${sanitizeText(cause.message)}` + + (typeof causeCode === "undefined" ? "" : ` (code=${causeCode})`), + ); + } + return details.join(": "); +} + +/** + * Remove credentials and query parameters from URLs in diagnostic text. + * + * @param value - Diagnostic text + * + * @returns Safe diagnostic text + */ +function sanitizeText(value: string): string { + return value.replace(/https?:\/\/[^\s)]+/g, (url) => safeUrl(url)); +} + +/** + * Get a string error code without assuming a specific error implementation. + * + * @param error - Error + * + * @returns Error code or nothing + */ +function errorCode(error: Error): string | undefined { + if (!("code" in error)) return; + const code = error.code; + return typeof code === "string" ? code : undefined; +} + /* ------------------------------------------------------------------------- */ /**