fix(plugins): fall back when GitHub asset downloads fail

Prefer the GitHub API asset-id route after the public release URL fails, and skip re-downloading official plugin packages when the live version already matches.
This commit is contained in:
tianma-if committed 2026-09-10 09:31:50 +08:00
1 parent 594c8c9ce7
commit f63d3cc69f
7 files changed
+164 -9

No files matched your search

@@ -53,6 +53,42 @@ describe("GitHub plugin release asset proxy", () => {
]);
});
test("falls back to the GitHub API asset endpoint when the public download URL fails", async () => {
const calls = [];
const buffer = await downloadGithubReleaseAssetByTag({
owner: "example",
repository: "edgeever-plugin",
releaseTag: "v1.2.3",
assetName: "main.js",
request: async (input) => {
const url = String(input);
calls.push(url);
if (url.includes("/releases/download/")) throw new TypeError("Failed to fetch");
if (url.includes("/releases/tags/")) {
return Response.json({
tag_name: "v1.2.3",
draft: false,
assets: [{
id: 42,
name: "main.js",
size: 18,
url: "https://api.github.com/repos/example/edgeever-plugin/releases/assets/42",
browser_download_url: "https://github.com/example/edgeever-plugin/releases/download/v1.2.3/main.js",
}],
});
}
if (url.endsWith("/releases/assets/42")) {
return new Response("export default {};", { headers: { "content-length": "18" } });
}
throw new Error(url);
},
});
expect(new TextDecoder().decode(buffer)).toBe("export default {};");
expect(calls[0]).toBe("https://github.com/example/edgeever-plugin/releases/download/v1.2.3/main.js");
expect(calls).toContain("https://api.github.com/repos/example/edgeever-plugin/releases/assets/42");
});
test("rejects release coordinates that could escape the GitHub download path", async () => {
await expect(downloadGithubReleaseAssetByTag({
owner: "example",
+23 -6
View File
@@ -170,12 +170,29 @@ export const downloadGithubReleaseAssetByTag = async ({
if (!hasValidRepositoryCoordinates(owner, repository) || !RELEASE_TAG_PATTERN.test(releaseTag)) {
throw new Error("Invalid GitHub release coordinates.");
}
const url = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/download/${encodeURIComponent(releaseTag)}/${encodeURIComponent(assetName)}`;
const response = await request(url, {
redirect: "follow",
headers: { "User-Agent": "EdgeEver" },
});
return readBoundedAsset(response, assetName);
const url = `https://github.com/${owner}/${repository}/releases/download/${releaseTag}/${assetName}`;
try {
const response = await request(url, {
redirect: "follow",
headers: { "User-Agent": "EdgeEver" },
});
return await readBoundedAsset(response, assetName);
} catch (error) {
const release = await readGithubReleaseByTag({ owner, repository, releaseTag, request });
const asset = release.assets.find((candidate) => candidate.name === assetName);
if (!asset) {
const missing = new GithubUpstreamError(`GitHub Release ${releaseTag} is missing ${assetName}.`, 404);
missing.cause = error;
throw missing;
}
return downloadGithubReleaseAsset({
owner,
repository,
assetId: String(asset.id),
assetName,
request,
});
}
};
export const registerPluginDistributionRoutes = (app: Hono<AppEnv>) => {
@@ -163,16 +163,32 @@ export type GithubAssetDownloader = (
) => Promise<ArrayBuffer>;
const downloadGithubAssetThroughApi: GithubAssetDownloader = async (coordinates, releaseTag, asset) => {
try {
const assetName = asset.name as "manifest.json" | "main.js" | "styles.css";
const download = async () => {
if (asset.id > 0) {
try {
return await api.downloadGithubPluginAssetById(
coordinates.owner,
coordinates.repository,
String(asset.id),
assetName,
);
} catch (error) {
if (!isGithubUnreachableError(error) && !(error instanceof ApiRequestError)) throw error;
}
}
return await api.downloadGithubPluginAsset(
coordinates.owner,
coordinates.repository,
releaseTag,
asset.name as "manifest.json" | "main.js" | "styles.css",
assetName,
);
};
try {
return await download();
} catch (error) {
if (isGithubUnreachableError(error)) {
throw new Error("Could not download the plugin package from your EdgeEver instance.");
throw new Error(`Could not download ${assetName} from your EdgeEver instance.`);
}
throw error;
}
@@ -118,6 +118,50 @@ describe("bundled plugin marketplace", () => {
});
});
test("does not download official plugin packages when the live version already matches", async () => {
const checksums = { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) };
const registry = parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-09-08T00:00:00.000Z",
entries: [{
id: "org.edgeever.plugins.ai-rss",
name: "EdgeEver AI RSS",
description: "RSS",
author: "EdgeEver",
publisher: "edgeever",
category: "News & AI",
repositoryUrl: "https://github.com/tianma-if/edgeever-ai-rss",
distribution: { type: "github", repositoryUrl: "https://github.com/tianma-if/edgeever-ai-rss" },
verification: { version: "0.5.5", checksums },
}],
});
const calls = [];
const request = async (input) => {
calls.push(String(input));
if (String(input).includes("/contents/manifest.json")) {
return new Response(JSON.stringify({
type: "plugin",
id: "org.edgeever.plugins.ai-rss",
name: "EdgeEver AI RSS",
version: "0.5.5",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["ui:notices"],
}));
}
throw new Error(`Unexpected request: ${input}`);
};
const resolved = await resolveOfficialPluginMarketplace(registry, request, async () => {
throw new Error("should not download plugin assets");
});
expect(resolved.resolutionErrors).toEqual({});
expect(resolved.entries[0].verification).toEqual({ version: "0.5.5", checksums });
expect(calls).toHaveLength(1);
});
test("keeps the pinned release and reports an error when live resolution fails", async () => {
const registry = parseMarketplaceRegistry({
registryVersion: "1",
@@ -1,6 +1,7 @@
import { parseMarketplaceRegistry, type MarketplaceEntry, type MarketplaceRegistry } from "@edgeever/plugin-api";
import {
downloadGithubExtension,
loadGithubRepositoryManifest,
type GithubAssetDownloader,
} from "@/lib/plugins/github-plugin-distribution";
import { isVersionOutdated } from "@/lib/version-check";
@@ -30,6 +31,16 @@ const resolveOfficialGithubEntry = async (
downloadAssetBytes?: GithubAssetDownloader,
): Promise<MarketplaceEntry> => {
if (entry.publisher !== "edgeever" || entry.distribution.type !== "github") return entry;
const live = await loadGithubRepositoryManifest(entry.distribution.repositoryUrl, request);
if (live.manifest.id !== entry.id) {
throw new Error("Official repository manifest id does not match the marketplace entry.");
}
if (isVersionOutdated(live.manifest.version, entry.verification.version)) {
throw new Error("Official repository version is older than the marketplace verified version.");
}
if (live.manifest.version === entry.verification.version && entry.verification.checksums?.manifestJson) {
return entry;
}
const downloaded = await downloadGithubExtension(entry.distribution.repositoryUrl, request, downloadAssetBytes);
if (downloaded.manifest.id !== entry.id) {
throw new Error("Official repository manifest id does not match the marketplace entry.");
+22
View File
@@ -171,6 +171,28 @@ describe("EdgeEver client HTTP contract", () => {
expect(Array.from(new Uint8Array(buffer))).toEqual([1, 2, 3]);
});
test("downloads GitHub plugin assets through the release-asset id route", async () => {
let requestUrl;
const client = createEdgeEverClient({
fetch: async (input) => {
requestUrl = String(input);
return new Response(new Uint8Array([4, 5]));
},
});
const buffer = await client.downloadGithubPluginAssetById(
"example-owner",
"example-plugin",
"42",
"styles.css",
);
expect(requestUrl).toBe(
"/api/v1/plugins/github/example-owner/example-plugin/assets/42/styles.css",
);
expect(Array.from(new Uint8Array(buffer))).toEqual([4, 5]);
});
test("reads GitHub plugin metadata through the instance proxy", async () => {
const calls = [];
const client = createEdgeEverClient({
+9
View File
@@ -1238,6 +1238,15 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/${encodeURIComponent(releaseTag)}/assets/${encodeURIComponent(assetName)}`,
),
downloadGithubPluginAssetById: (
owner: string,
repository: string,
assetId: string,
assetName: "manifest.json" | "main.js" | "styles.css",
) => requestArrayBuffer(
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/assets/${encodeURIComponent(assetId)}/${encodeURIComponent(assetName)}`,
),
getGithubPluginRepositoryManifest: async (owner: string, repository: string) => {
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/manifest`;
const { context, response } = await send(path, undefined, { setJsonContentType: false });