www.example.com 且源站配置为 Android APK 母包 所在的对象存储 COS 自定义域名,详情可参见 存储桶切换自定义域名 。配置如下图所示:
const CUSTOM_BLOCK_VALUE_LENGTH = 10240;const APK_SIGNING_BLOCK_MAGIC_LENGTH = 16;const APK_SIGNING_BLOCK_OFFSET_LENGTH = 8;const APK_COMMENT_LENGTH = 512;class EdgePack {totalSize;signVersion;centralDirectoryOffset;customBlockValueStart;customBlockValueEnd;rangeRelativeOffset;customInfo;constructor() {/** @type {number} */this.totalSize = null;this.signVersion = null;this.centralDirectoryOffset = null;this.customBlockValueStart = null;this.customBlockValueEnd = null;this.rangeRelativeOffset = null;/** @type {string} */this.customInfo = null;/** @type {string} */this.customName = null;/** @type {number[]} */this.customRange = null;/** @type {boolean} */this.invalidRange = false; // 标记是否为非法 Range}async handle(event) {const { request } = event;const headers = new Headers(request.headers);const modifiedRequest = new Request(request, { headers });if (!this.checkRequest(modifiedRequest)) {// 如果检测到非法 Range 格式,直接返回 416if (this.invalidRange) {const responseHeaders = new Headers();// 此时还未获取到 totalSize,无法设置 Content-Range// responseHeaders.set("Content-Range", `bytes */0`);return event.respondWith(new Response("", {status: 416,statusText: "Range Not Satisfiable",headers: responseHeaders,}));}// 其他情况 passthroughreturn;}let response = null;try {const headHeaders = new Headers(modifiedRequest.headers);// 删除 Range 头,获取正确的 Content-LengthheadHeaders.delete("Range");const headRequest = new Request(modifiedRequest.url, {method: "HEAD",headers: headHeaders,});response = await fetch(headRequest, { redirect: "manual" });} catch (err) {const error = {code: "FETCH_ORIGIN_ERROR",message: err?.message,};response = new Response(JSON.stringify(error), {status: 590,});}if (!this.checkResponse(response)) {// 如果是非法 Range(格式错误或范围错误),返回 416 状态码if (this.invalidRange && this.totalSize !== null) {const headers = new Headers();headers.set("Content-Range", `bytes */${this.totalSize}`);return event.respondWith(new Response("", {status: 416,statusText: "Range Not Satisfiable",headers: headers,}));}return event.respondWith(response);}if (!!this.customName) {response.headers.set("Content-Disposition",`attachment; filename="${this.customName}"`);}// 处理单一范围的 Range 请求if (this.customRange) {const [rangeStart, rangeEnd] = this.customRange;let actualStart = rangeStart;let actualEnd = rangeEnd;let contentLength = this.totalSize;if (rangeStart === null && rangeEnd !== null) {// bytes=-suffix 格式:最后 suffix 个字节actualStart = Math.max(0, this.totalSize - rangeEnd);actualEnd = this.totalSize - 1;contentLength = Math.min(rangeEnd, this.totalSize);} else if (rangeEnd !== null) {// bytes=start-end 格式contentLength = rangeEnd - rangeStart + 1;actualEnd = rangeEnd;} else if (rangeStart !== null) {// bytes=start- 格式actualEnd = this.totalSize - 1;contentLength = this.totalSize - rangeStart;}response.headers.set("Content-Range",`bytes ${actualStart}-${actualEnd}/${this.totalSize}`);response.headers.set("Content-Length", contentLength.toString());response.headers.set("Accept-Ranges", "bytes");response = new Response(null, {status: 206,statusText: "Partial Content",headers: response.headers,});}// 处理非 Range 请求else {// 非 Range 请求也应该声明支持 Rangeresponse.headers.set("Accept-Ranges", "bytes");}const streamResponse = new Response(await this.combineStreams(modifiedRequest),response);return event.respondWith(streamResponse);}getRelativeOffset(response) {const start = this.customBlockValueStart;const end = this.customBlockValueEnd;const range = response.headers.get("Content-Range");if (!range) return start;const match = range.match(/bytes\\s*(\\d*)-(\\d*)/i);if (!match || match?.length < 2) {return start;}const rangeStart = +match[1];const rangeEnd = +match[2];if (rangeEnd < start || rangeStart > end) {return null;}return start - rangeStart;}checkRequest(request) {if (request.method !== "GET") {// 非 GET 请求,使用 passthrough 回源return false;}if (request.headers.has("Range")) {const range = request.headers.get("Range");// 检查是否为多 Range 请求if (range.includes(",")) {// Multi-Range 请求,使用 passthrough 回源console.log("Multi-Range detected, using passthrough");return false;}// 单 Range 请求else {// 使用更精确的正则:分别匹配 bytes=start-end 和 bytes=-suffix// 捕获组: (1)start (2)end 用于 start-end 格式// (3)suffix 用于 -suffix 格式const match = range.match(/bytes=(?:(\\d+)-(\\d*)|-(\\d+))$/i);if (match) {// 处理三种 Range 格式:// 1. bytes=start-end: 指定范围// 2. bytes=start-: 从 start 到文件末尾// 3. bytes=-suffix: 最后 suffix 个字节// 检查是否为 bytes=-suffix 格式(第3个捕获组有值)if (match[3] !== undefined) {// bytes=-suffix 格式:最后 suffix 个字节const suffix = +match[3];// bytes=-0 是无效的范围请求,应该返回 416if (suffix === 0) {console.warn("Invalid Range: bytes=-0 is not satisfiable");this.invalidRange = true;return false;}this.customRange = [null, suffix];} else {// bytes=start-end 或 bytes=start- 格式const start = match[1] ? +match[1] : null;const end = match[2] && match[2] !== "" ? +match[2] : null;// 检测 start > end 的非法情况if (start !== null && end !== null && start > end) {console.warn("Invalid Range: start > end:", range);this.invalidRange = true;return false;}this.customRange = [start, end];}} else {console.warn("Invalid Range format:", range);this.invalidRange = true;return false;}}}const { pathname, searchParams } = new URL(request.url);// 获取渠道信息参数,存放在 comment 参数中const comment = searchParams?.get("comment");// 非 APK 文件,或是没有渠道信息参数,使用 passthrough 回源if (!pathname.endsWith(".apk") || !comment) {return false;}this.customInfo = comment;const filename = searchParams?.get("filename");if (filename) {this.customName = filename;}return true;}/*** @param {number} fileSize* @returns {boolean}*/validateRange(fileSize) {// 验证单 Rangeif (!this.customRange) {return true;}const [rangeStart, rangeEnd] = this.customRange;// 处理 bytes=-suffix 格式if (rangeStart === null && rangeEnd !== null) {// suffix 超出文件大小,修正为文件大小if (rangeEnd > fileSize) {this.customRange = [null, fileSize];return true;}return true;}// 处理 bytes=start-end 和 bytes=start- 格式if (rangeStart !== null) {// start 超出文件大小范围,标记为非法并返回 falseif (rangeStart >= fileSize) {this.invalidRange = true;return false;}// start 在范围内,但 end 超出范围,修正为 start-if (rangeEnd !== null && rangeEnd >= fileSize) {this.customRange = [rangeStart, null];return true;}}return true;}checkResponse(response) {if (response.status !== 200 && response.status !== 206) {return false;}const contentLength = response.headers.get("Content-Length");if (response.body === null || contentLength === null) {return false;}this.totalSize = Number(contentLength);if (this.customRange && !this.validateRange(this.totalSize)) {// Range 请求超出范围,标记 invalidRange 并返回 false// invalidRange 已在 validateRange() 中设置return false;}const cosOffsetHeader = response.headers.get("x-cos-meta-edgepack-offset");const cosTypeHeader = response.headers.get("x-cos-meta-edgepack-type");if (!cosOffsetHeader || !cosTypeHeader) {return false;}this.signVersion = cosTypeHeader;this.centralDirectoryOffset = Number(cosOffsetHeader);if (this.signVersion === "v1") {this.customBlockValueStart = this.totalSize - APK_COMMENT_LENGTH;this.customBlockValueEnd = this.totalSize;} else {this.customBlockValueStart =this.centralDirectoryOffset -CUSTOM_BLOCK_VALUE_LENGTH -APK_SIGNING_BLOCK_MAGIC_LENGTH -APK_SIGNING_BLOCK_OFFSET_LENGTH;this.customBlockValueEnd = this.centralDirectoryOffset;}this.rangeRelativeOffset = this.getRelativeOffset(response);if (this.rangeRelativeOffset === null) {return false;}return true;}async combineStreams(request) {// 处理单 Range 或完整请求let contentLength = this.totalSize;if (this.customRange) {const [rangeStart, rangeEnd] = this.customRange;if (rangeStart === null && rangeEnd !== null) {// bytes=-suffix 格式:最后 suffix 个字节contentLength = Math.min(rangeEnd, this.totalSize);} else if (rangeEnd !== null) {// bytes=start-end 格式contentLength = rangeEnd - rangeStart + 1;} else if (rangeStart !== null) {// bytes=start- 格式contentLength = this.totalSize - rangeStart;}}const { readable, writable } = new FixedLengthStream(contentLength);this.handleStream(request, writable);return readable;}/*** 写入指定范围的数据* 使用 pipeTo 优化性能,适用于单 Range 请求* @private*/async _writeRangeData(request, writable, actualStart, actualEnd) {const comment = this.customInfo;const encoder = new TextEncoder();const section = encoder.encode(comment);// Header 部分:签名块之前的数据if (actualStart < this.customBlockValueStart) {const apkHeader = await this.apkHeaderStream(request,actualStart,actualEnd);if (apkHeader !== null) {try {await apkHeader.pipeTo(writable, {preventClose: true,});} catch (e) {console.error("HEADER_STREAM_ERROR: ", e);}}}// Body 部分:签名块(渠道信息注入区域)if (actualStart < this.customBlockValueEnd &&(actualEnd === null || actualEnd >= this.customBlockValueStart)) {const apkBody = await this.apkBodyStream(request,section,actualStart,actualEnd);if (apkBody !== null) {const apkBodyStream = apkBody.stream();try {await apkBodyStream.pipeTo(writable, {preventClose: true,});} catch (e) {console.error("BODY_STREAM_ERROR: ", e);}}}// Tail 部分:签名块之后的数据if (actualEnd === null || actualEnd >= this.customBlockValueEnd) {const apkTail = await this.apkTailStream(request,actualStart,actualEnd);if (apkTail !== null) {try {await apkTail.pipeTo(writable, {preventClose: true,});} catch (e) {console.error("TAIL_STREAM_ERROR: ", e);}}}}async handleStream(request, writable) {try {const [rangeStart, rangeEnd] = this.customRange || [0, null];let actualStart = rangeStart;let actualEnd = rangeEnd;if (rangeStart === null && rangeEnd !== null) {actualStart = Math.max(0, this.totalSize - rangeEnd);actualEnd = this.totalSize - 1;}await this._writeRangeData(request, writable, actualStart, actualEnd);} catch (err) {console.error("HANDLE_STREAM_ERROR: ", err);} finally {let writer = writable.getWriter();writer.close();writer.releaseLock();}}async apkHeaderStream(request, actualStart, actualEnd) {const headers = new Headers(request.headers);let headerStart = 0;let headerEnd = this.customBlockValueStart - 1;if (actualStart > 0) {headerStart = Math.max(headerStart, actualStart);}if (actualEnd !== null) {headerEnd = Math.min(headerEnd, actualEnd);}if (headerStart > headerEnd) {return null;}headers.set("Range", `bytes=${headerStart}-${headerEnd}`);// 获取签名块之前的部分const headResponse = await fetch(request, {headers: headers,});return headResponse.body;}async apkBodyStream(request,section = null,actualStart = 0,actualEnd = null) {const headers = new Headers(request.headers);let bodyStart = this.customBlockValueStart;let bodyEnd = this.customBlockValueEnd - 1;if (actualStart > this.customBlockValueStart) {bodyStart = Math.max(bodyStart, actualStart);}if (actualEnd !== null) {bodyEnd = Math.min(bodyEnd, actualEnd);}if (bodyStart > bodyEnd) {return null;}headers.set("Range", `bytes=${bodyStart}-${bodyEnd}`);const middleResponse = await fetch(request, {headers: headers,});const reader = middleResponse.body.getReader();let outputBuffers = [];try {let handledBytes = bodyStart;while (true) {const result = await reader.read();if (result.done) {console.log("APK_BODY_STREAM_DONE");break;}const startByteOffset = handledBytes;const buffer = result.value;handledBytes += buffer.byteLength;// 渠道信息在签名块中的绝对位置const channelAbsoluteStart = this.customBlockValueStart;const channelAbsoluteEnd = channelAbsoluteStart + section.byteLength;// 计算当前 buffer 中需要替换的范围const min = Math.max(startByteOffset, channelAbsoluteStart);const max = Math.min(channelAbsoluteEnd, handledBytes);if (min < max) {const bufferStart = min - startByteOffset;const sectionStart = min - channelAbsoluteStart;const sectionEnd = max - channelAbsoluteStart;const replacement = section.subarray(sectionStart, sectionEnd);new Uint8Array(buffer).set(replacement, bufferStart);}outputBuffers.push(buffer);}} catch (err) {console.error("APK_BODY_STREAM_ERROR: ", err);}return new Blob(outputBuffers);}async apkTailStream(request, actualStart, actualEnd) {const headers = new Headers(request.headers);let tailStart = this.customBlockValueEnd;let tailEnd = this.totalSize - 1;if (actualStart > this.customBlockValueEnd) {tailStart = Math.max(tailStart, actualStart);}if (actualEnd !== null) {tailEnd = Math.min(tailEnd, actualEnd);}if (tailStart > tailEnd) {return null;}headers.set("Range", `bytes=${tailStart}-${tailEnd}`);const tailResponse = await fetch(request, {headers: headers,});return tailResponse.body;}}async function handleEvent(event) {const edgepack = new EdgePack();await edgepack.handle(event);}addEventListener("fetch", handleEvent);

www.example.com 且文件后缀为.apk时,即可触发边缘函数进行动态打包。http://www.example.com/v2_src.apk?comment=test 即可触发边缘函数,动态地将渠道信息注入到指定位置,其中 comment 为您在 创建用于触发渠道信息写入的边缘函数 中定义的渠道参数。以 v2-VasDolly 方式为例,可以使用 VasDolly 多渠道打包工具来读取动态注入的渠道信息:

文档反馈