/* eslint-disable @typescript-eslint/no-non-null-assertion */ import path from 'path'; import { defaultTo, noop, defaults } from 'lodash'; import superagent from 'superagent'; import { getProjectConfig } from '@miniu/utils'; import request from '../../utils/request'; import { sleep, QRTerminal, QRToDataURL, QRImage } from '../../utils/util'; import packAmr from '../../utils/packAmr'; import { tmpdir } from 'os'; import { SubApplicationType } from './app.list'; import { TYPES_BUNDLEID } from './util'; import { getRealQrUrl, resolveQrCodeSchema } from '../../utils/qrcode'; import { getMinidev } from './minidev'; // Generated by https://quicktype.io let pushID = 0; export interface LocalBuild { debugQrcodeUrl: string; deployVersion: string; packageUrlSchema: string; } export interface CloudBuild { data: CloudBuildData; errorCode: string; message: string; success: boolean; } export interface CloudBuildData { taskId: string; } // Generated by https://quicktype.io export interface CloudBuildResult { data: CloudBuildData; errorCode: string; message: string; success: boolean; } export interface CloudBuildData { cdnError: null; components: any[]; packageQrcode: string | null; cdnSuccess: null; zipFile: string; cdnRoot: null; packageSchema: string; sourceUrl: null; size: number; plugin: null; subPackages: any[]; artifactUrl: null; status: 'BUILDING' | 'PUSHING' | 'COMPLETE' | 'PUSHCOMPLETE'; } export interface MiniPreviewOptions { isDebug?: boolean; // 是否是真机调试 /** * 构建模式 */ buildMode?: EPreviewMode; localBuild?: boolean; /** * 本地项目地址 */ project: string; /** * 小程序appId */ appId: string; /** * 云构建是否不做缓存 */ disableCache?: boolean; /** * clientType */ clientType?: keyof typeof TYPES_BUNDLEID; /** * 落地页 * @example * 'page/shop/detail?id=10&from=name' */ page?: string; /** * app.js的onLaunch中取得 * @example * 'name=demo&fromId=11' */ launch?: string; /** * 返回二维码文件的格式 * @default image */ qrcodeFormat?: 'terminal' | 'base64' | 'image'; /** * 二维码文件保存路径 */ qrcodeOutput?: string; /** * 忽略 Webview 域名合法性检查 * @default false */ ignoreWebviewCheck?: boolean; /** * 预览流程回调 */ onProgressUpdate?(info: { /** * LOCAL_PACKAGE: 本地打包成功 * UPLOAD_SUCCESS: 上传代码成功 * BUILD_SUCCESS: 云端构建成功 */ status: 'LOCAL_PACKAGE' | 'UPLOAD_SUCCESS' | 'BUILD_SUCCESS'; data: any; }): void; cacheDir?: string; } interface ExtraInfo { appId: string; bundleId: string; client: string; doneCommand: string; fileFormat: string; scene: string; tfInstall: boolean; unfoldToCDN: boolean; ampeUploadDebugRequest?: any; channelId?: string; } /** * 获取真机调试时的uuid */ const getDebugUUID = async function () { try { const p = superagent .get('https://hpmweb.alipay.com/tyro/uuid') .set('origin', 'https://hpmweb.alipay.com') .set('referer', 'https://hpmweb.alipay.com'); const result = await p; return result.text; } catch (e) { console.log('获取UUID失败'); throw e; } }; /** * 真机预览和真机调试共用的参数 * @param options */ function getCloudBuildOptions(options) { const { appId, page, launch, ignoreWebviewCheck, subApplicationType, clientType, hostAppId, productId, deviceId, disableCache = false, } = options; const operationType = 'buildpush'; const params = [ '--scriptName', 'buildpack', '--ADAPTOR_NAME', 'alipay', '--limitation', '--preview', ]; // 无缓存构建 if (disableCache) { params.push('--nocache'); } const extraInfo: ExtraInfo = { appId, bundleId: TYPES_BUNDLEID[clientType], client: 'sdk', doneCommand: 'pack', fileFormat: 'tar', scene: 'DEBUG', tfInstall: false, unfoldToCDN: false, }; // 如果是AMPE端,需要扩展字段 if (clientType === 'ampe') { extraInfo.ampeUploadDebugRequest = { productId, deviceId, hostAppId, }; } const env = { ADAPTOR_NAME: 'alipay', DEBUG_ENV: 1, importModule: 1 }; const extendInfo = { launchParams: { // 忽略 request 域名合法性检查 ignoreHttpReqPermission: true, // 忽略 Webview 域名合法性检查 ignoreWebViewDomainCheck: ignoreWebviewCheck, } as any, usePresetPopmenu: 'YES', }; if (page) { extendInfo.launchParams.page = page; } if (launch) { extendInfo.launchParams.query = launch; } const packageInfo = { mainUrl: '/index.html', extendInfo: JSON.stringify(extendInfo), clientType: TYPES_BUNDLEID[clientType], needSync: 'true', }; if (page) { packageInfo.mainUrl += `#${page.replace(/\?.*/, '')}`; } // 小程序插件 if (subApplicationType === 'TINYAPP_PLUGIN') { params.push('--pluginId'); params.push(appId); } const plugins = []; return { operationType, params, extraInfo, env, packageInfo, plugins, }; } /** * 修改真机调试的入参 */ async function changeDebugParams(options) { const UUID = await getDebugUUID(); options.plugins = [ ...options.plugins, { key: 'bugmeng', // 真机调试 options: { tinybugme: true, }, }, { key: 'tyro', // 插桩 options: { fast: true, uuid: UUID, }, }, ]; options.extraInfo.channelId = UUID; const { extendInfo } = options.packageInfo; const jsonParseExtendInfo = JSON.parse(extendInfo); jsonParseExtendInfo.launchParams.isRemoteX = true; jsonParseExtendInfo.launchParams.channelId = UUID; jsonParseExtendInfo.launchParams.tyroId = UUID; options.packageInfo.extendInfo = JSON.stringify(jsonParseExtendInfo); } // 最大轮训次数, 420次,21分钟 const MAX_LOOP = 420; // 容错次数 const MAX_ERROR_NUM = 5; // 处理云构建出错的错误信息 function handleCloudBuildErrorMessage(message) { try { const formatResult = JSON.parse(message.replace(/"/g, '"')) || {}; const { resultMessage } = formatResult; return resultMessage; } catch (e) { return message.replace(/\\n/g, '\n'); } } async function queryCloudBuildResult( appId, taskId ): Promise<{ success: boolean; buildErrorInfo?: { buildErrorMessage: string; // 构建后台报错信息 buildCommonMsg?: string; // 构建出错的通用信息 }; packageQrcode?: string; packageSchema?: string; }> { let count = 1; let errorCount = 0; // 当前报错次数 const failMsg = taskId; // 每3秒轮询一次 while (count < MAX_LOOP) { await sleep(3000); count += 1; const result = await request({ method: 'POST', host: 'ide', path: '/cli/miniapp/cloudBuildResult.json', needSign: true, data: { appId, params: { taskId, }, }, }); const { data, success } = result; if (!success) { if (errorCount < MAX_ERROR_NUM) { errorCount += 1; continue; } const errorMessage = handleCloudBuildErrorMessage(result.message || ''); return { success: false, buildErrorInfo: { buildCommonMsg: failMsg, buildErrorMessage: errorMessage, }, }; } if (data.status === 'BUILDING' || data.status === 'PUSHING') { continue; } const packageQrcode = data.packageQrcode || ''; return { success: true, packageQrcode: packageQrcode.replace(/&/g, '&'), packageSchema: data.packageSchema.replace(/&/g, '&'), }; } return { success: false, buildErrorInfo: { buildCommonMsg: failMsg, buildErrorMessage: '', }, }; } enum EPreviewResultStat { ok = 'ok', failed = 'failed', abort = 'abort', } export interface MiniPreviewResult { /** * 预览的结果 */ stat?: EPreviewResultStat; message?: string; // 云构建失败时的错误信息 /** * 错误信息 - 云构建失败时,抛出错误信息 */ data?: { message?: string; buildMessage?: string; }; /** * 预览二维码的在线地址 */ packageQrcode?: string; /** * 支付宝schema链接 */ schema?: string; /** * schema对应的二维码 */ qrcode?: string; /** * 程序类型 */ subApplicationType?: SubApplicationType; /** * 真机调试使用 */ uuid?: string; } export enum EPreviewMode { CLOUD = 'CLOUD', LOCAL = 'LOCAL', } /** * 本地小程序代码预览 - 云构建 */ async function cloudBuild(options: MiniPreviewOptions, taskId): Promise { const { isDebug = false } = options; const projectConfig = await getProjectConfig(options.project); const params = defaults(options, { clientType: 'alipay', onProgressUpdate: noop, page: projectConfig.appJsonConfig.mainUrl, ignoreWebviewCheck: false, subApplicationType: projectConfig.subApplicationType, }); const buildParams = getCloudBuildOptions(params); const { appId, onProgressUpdate } = params; const destDirPath = await tmpdir(); const file = await packAmr({ cwd: projectConfig.projectPath, destDir: destDirPath, miniProjectConfig: projectConfig.miniProjectConfig, }); onProgressUpdate({ status: 'LOCAL_PACKAGE', data: file, }); if (taskId !== pushID) { return { stat: EPreviewResultStat.abort, }; } try { // 真机调试 if (isDebug) { await changeDebugParams(buildParams); } const buildResult = await request({ method: 'POST', host: 'ide', path: '/cli/miniapp/cloudBuild.json', needSign: true, beforeSend(req) { req.attach('file', file); }, data: { appId, params: buildParams, }, }); if (!buildResult) { throw new Error('云端构建失败'); } if (!buildResult.success) { throw new Error(buildResult.message || '云端构建失败'); } onProgressUpdate({ status: 'UPLOAD_SUCCESS', data: buildResult.data, }); const cloudBuildResult = await queryCloudBuildResult(appId, buildResult.data.taskId); onProgressUpdate({ status: 'BUILD_SUCCESS', }); if (cloudBuildResult.success) { let qrResult; const { qrcodeFormat } = params; if (qrcodeFormat === 'base64') { qrResult = await QRToDataURL(cloudBuildResult.packageSchema!); } else if (qrcodeFormat === 'terminal') { qrResult = await QRTerminal(cloudBuildResult.packageSchema!); } else { const qrcodeOutput = defaultTo(params.qrcodeOutput, path.resolve('mini.preview.png')); await QRImage(qrcodeOutput, cloudBuildResult.packageSchema!); qrResult = qrcodeOutput; } const result = { stat: EPreviewResultStat.ok, schema: cloudBuildResult.packageSchema, packageQrcode: cloudBuildResult.packageQrcode, qrcode: qrResult, subApplicationType: projectConfig.subApplicationType, uuid: '', }; if (isDebug) { const { extraInfo } = buildParams; const { channelId } = extraInfo; result.uuid = channelId || ''; } return result; } // 为了兼容,将错误信息直接throw // eslint-disable-next-line no-throw-literal throw { message: cloudBuildResult.buildErrorInfo!.buildCommonMsg, buildMessage: cloudBuildResult.buildErrorInfo!.buildErrorMessage, }; } catch (e) { if (e.code === 'ABORTED') { return { stat: EPreviewResultStat.abort, }; } console.log('小程序预览出错,错误信息为', e); return { stat: EPreviewResultStat.failed, message: `${ e.buildMessage ? `${e.buildMessage}。若报错详情无法解决你的问题,请联系支付宝开发者排查,任务ID: ${e.message}` : `云端构建失败: ${e.message}` }`, data: { message: e.message || '', buildMessage: e.buildMessage, }, schema: '', packageQrcode: '', qrcode: '', subApplicationType: projectConfig.subApplicationType, }; } } /** * 本地小程序代码预览 */ export default async function miniPreview(options: MiniPreviewOptions): Promise { const handler = options.buildMode === EPreviewMode.LOCAL || options.localBuild ? localBuild : cloudBuild; delete options.buildMode; ++pushID; return await handler(options, pushID); } async function localBuild(options: MiniPreviewOptions): Promise { const projectConfig = await getProjectConfig(options.project); try { const { qrcodeSchema } = await getMinidev().preview({ appId: options.appId, project: options.project, page: options.page, query: options.launch, ignoreHttpReqPermission: options.ignoreWebviewCheck, ignoreWebViewDomainCheck: options.ignoreWebviewCheck, cacheDir: options.cacheDir, clientType: options.clientType, }); const { qrcodeFormat, qrcodeOutput } = options; const qrResult = await resolveQrCodeSchema(qrcodeSchema!, { qrcodeFormat, qrcodeOutput }); return { stat: EPreviewResultStat.ok, schema: getRealQrUrl(qrcodeSchema!)!, packageQrcode: getRealQrUrl(qrcodeSchema!)!, qrcode: qrResult, subApplicationType: projectConfig.subApplicationType, }; } catch (e) { if (e.code === 'ABORTED') { return { stat: EPreviewResultStat.abort, }; } console.log('小程序预览出错,错误信息为', e); return { stat: EPreviewResultStat.failed, message: e.message || '', schema: '', packageQrcode: '', qrcode: '', subApplicationType: projectConfig.subApplicationType, }; } }