All files / src/utils binary-response.ts

100% Statements 20/20
86.95% Branches 20/23
100% Functions 4/4
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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                                                                    42x 42x                     13x 5x   8x 8x 2x 2x 2x       1x     6x 6x 4x   2x 2x 1x   1x                       5x 5x                         12x            
/**
 * Shared handling for binary (file-download) HTTP responses.
 *
 * StackOne serves file downloads as raw binary with the file's own MIME type and a
 * Content-Disposition header - never as the usual JSON envelope. Both the HTTP tool path
 * (RequestBuilder) and the RPC tool path (RpcClient) must therefore decide JSON-vs-file by
 * Content-Type and return the bytes plus metadata instead of forcing a JSON parse.
 */
 
/**
 * Result of a non-JSON (file-download) response: the raw bytes plus metadata.
 *
 * Note: `content` is a raw `Buffer`, not a `JsonValue`. `JSON.stringify` turns a Buffer into a
 * `{ type: 'Buffer', data: [...] }` byte array (not the file, and potentially huge), so callers
 * that re-serialize tool results (e.g. for an LLM) should strip or transform this key.
 */
export interface BinaryDownloadResult {
	content: Buffer;
	contentType: string;
	statusCode: number;
	headers: Record<string, string>;
	fileName: string | null;
}
 
/**
 * Whether a response body should be parsed as JSON based on its Content-Type.
 *
 * Only genuine JSON media types are parsed (`application/json` and structured suffixes such
 * as `application/problem+json`). Anything else - including a missing Content-Type - is treated
 * as opaque content (a file download), so the raw bytes are returned instead of being
 * force-decoded as UTF-8/JSON. This mirrors how the StackOne generated SDKs default unknown
 * bodies to `application/octet-stream`.
 */
export function isJsonContentType(contentType: string): boolean {
	const mediaType = contentType.split(';')[0]?.trim().toLowerCase() ?? '';
	return mediaType === 'application/json' || mediaType.endsWith('+json');
}
 
/**
 * Extract the filename from a Content-Disposition header value, if present.
 *
 * Handles both the plain `filename="example.pdf"` form and the RFC 5987 extended
 * `filename*=UTF-8''example%20file.pdf` form (which is percent-decoded and takes precedence
 * when present). Returns null when no filename is present.
 */
export function filenameFromContentDisposition(value: string | null): string | null {
	if (!value) {
		return null;
	}
	const extended = value.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i);
	if (extended?.[1]) {
		const encoded = extended[1].trim();
		try {
			return decodeURIComponent(encoded);
		} catch {
			// Malformed percent-encoding: fall back to the raw value rather than throwing and
			// breaking an otherwise-valid download over a bad Content-Disposition header.
			return encoded;
		}
	}
	const quoted = value.match(/filename\s*=\s*"([^"]*)"/i);
	if (quoted) {
		return quoted[1]?.trim() || null;
	}
	const bare = value.match(/filename\s*=\s*([^;]+)/i);
	if (bare?.[1]) {
		return bare[1].trim().replace(/^"+|"+$/g, '') || null;
	}
	return null;
}
 
/**
 * Read a non-JSON `Response` into a {@link BinaryDownloadResult} (bytes + metadata).
 *
 * Assumes the caller has already decided the body is non-JSON (see {@link isJsonContentType});
 * consumes the response body via `arrayBuffer()`.
 */
export async function binaryDownloadFromResponse(
	response: Response,
): Promise<BinaryDownloadResult> {
	const contentType = response.headers.get('content-type') ?? '';
	return {
		content: Buffer.from(await response.arrayBuffer()),
		contentType: contentType || 'application/octet-stream',
		statusCode: response.status,
		headers: Object.fromEntries(response.headers.entries()),
		fileName: filenameFromContentDisposition(response.headers.get('content-disposition')),
	};
}
 
/**
 * Type guard for a {@link BinaryDownloadResult} - true when `content` carries raw bytes.
 */
export function isBinaryDownloadResult(value: unknown): value is BinaryDownloadResult {
	return (
		typeof value === 'object' &&
		value !== null &&
		Buffer.isBuffer((value as { content?: unknown }).content)
	);
}