feat(plugins): count marketplace downloads and installs

This commit is contained in:
t8y2
2026-09-15 14:24:11 +08:00
parent 83c8447b4d
commit 08c163fe32
6 changed files with 178 additions and 1 deletions
+1
View File
@@ -106,3 +106,4 @@ dbx-devtest-connections.json
# Real database integration credentials (see docs/TEST_ENVIRONMENTS.md)
/docs/TEST_ENVIRONMENTS.md
.wrangler/
@@ -15,7 +15,7 @@ import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -173,6 +173,7 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) {
version: listing.plugin.latestVersion,
});
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion);
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
@@ -52,6 +52,22 @@ export function listingRepositoryCanVerify(repository: PluginRepository): boolea
return repository.kind === "official" || repository.kind === "enterprise";
}
const INSTALL_BEACON_URL = "https://dbxio.com/api/plugins/install";
// Fire-and-forget install beacon for marketplace statistics; never blocks or fails the install.
export function beaconPluginInstall(pluginId: string, version: string): void {
try {
void fetch(INSTALL_BEACON_URL, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ id: pluginId, version }),
keepalive: true,
}).catch(() => undefined);
} catch {
// Statistics are best-effort.
}
}
export function filterMarketplacePluginListings(listings: readonly MarketplacePluginListing[], query: string, repositoryId: string): MarketplacePluginListing[] {
const normalizedQuery = query.trim().toLocaleLowerCase();
return listings.filter((listing) => {
+34
View File
@@ -0,0 +1,34 @@
# plugin-stats-worker
Cloudflare Worker that counts plugin marketplace traffic. Deployed on the same
Cloudflare account as the dbxio.com site (`pnpm dlx wrangler deploy` from this
directory; OAuth login required).
## Routes
- `dl.dbxio.com/plugins/*` — counts every `GET` of a marketplace artifact, then
passes the request through to the R2 custom domain. Artifact bytes, headers,
and Range semantics are untouched; the route sits in front of the origin only.
- `dbxio.com/api/plugins/*` —
`POST /api/plugins/install` is the fire-and-forget beacon the desktop app
sends after a successful marketplace install;
`GET /api/plugins/stats` returns the current counters
(`{ downloads: {key: n}, installs: {key: n} }`) and is how counters should be
read (the wrangler CLI can show a stale/split view of a fresh namespace).
## Storage
Single KV namespace `plugin_stats` (binding `PLUGIN_STATS`):
- `dl:{pluginId}:{version}` — passive artifact download count
- `inst:{pluginId}:{version}` — client-reported install count
Counters are best-effort (KV read-modify-write can drop counts under same-key
concurrency; acceptable for decorative stats). Inspect values with:
```sh
pnpm dlx wrangler kv key list --namespace-id 483dcc36cd3c4da5af5cdbb4532166f2 --prefix "dl:io.dbx.ssh"
pnpm dlx wrangler kv key get --namespace-id 483dcc36cd3c4da5af5cdbb4532166f2 "dl:io.dbx.ssh:0.4.73"
```
No display in the app yet; counts accrue until the marketplace UI surfaces them.
+110
View File
@@ -0,0 +1,110 @@
// Counts plugin marketplace traffic on Cloudflare without touching artifact bytes.
//
// Routes (see wrangler.json):
// - dl.dbxio.com/plugins/*: counts each GET of a marketplace artifact, then passes
// the request through to the R2 custom domain origin. Same-zone subrequests do not
// re-enter Workers, so the pass-through cannot loop.
// - dbxio.com/api/plugins/install: beacon endpoint the desktop app calls after a
// successful marketplace install. Decorative statistics only: no auth, no PII,
// failures are silently ignored by clients.
// - dbxio.com/api/plugins/stats: read view of the counters, for later display in
// the marketplace UI and for ops checks.
//
// Counters live in KV under `dl:{pluginId}:{version}` (passive downloads) and
// `inst:{pluginId}:{version}` (client beacons). KV read-modify-write can drop
// counts under same-key concurrency; acceptable for decorative stats. NOTE: the
// wrangler CLI/API view of a freshly created namespace can lag or split from the
// runtime view for a long time — always read counters through the stats endpoint,
// not `wrangler kv key get`.
type KvNamespaceBinding = {
get(key: string): Promise<string | null>;
put(key: string, value: string): Promise<void>;
list(options?: { prefix?: string }): Promise<{ keys: { name: string }[] }>;
};
type Env = {
PLUGIN_STATS: KvNamespaceBinding;
};
const DOWNLOAD_PATTERN = /^\/plugins\/([A-Za-z0-9._-]{1,64})\/([0-9A-Za-z.+-]{1,32})\//;
const PLUGIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,32}$/;
const INSTALL_BODY_LIMIT_BYTES = 512;
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};
async function increment(env: Env, key: string): Promise<void> {
try {
const current = await env.PLUGIN_STATS.get(key);
const next = Number.parseInt(current ?? "0", 10) || 0;
await env.PLUGIN_STATS.put(key, String(next + 1));
} catch (error) {
console.error(`plugin-stats increment failed for ${key}`, error);
}
}
async function readCounters(env: Env, prefix: string): Promise<Record<string, number>> {
const listing = await env.PLUGIN_STATS.list({ prefix });
const counters: Record<string, number> = {};
await Promise.all(
listing.keys.map(async (entry) => {
try {
counters[entry.name] = Number.parseInt((await env.PLUGIN_STATS.get(entry.name)) ?? "0", 10) || 0;
} catch {
counters[entry.name] = 0;
}
}),
);
return counters;
}
function emptyResponse(status: number): Response {
return new Response(null, { status, headers: CORS_HEADERS });
}
async function handleInstallBeacon(request: Request, env: Env): Promise<Response> {
if (request.method === "OPTIONS") return emptyResponse(204);
const url = new URL(request.url);
if (url.pathname === "/api/plugins/stats") {
if (request.method !== "GET") return emptyResponse(405);
const [downloads, installs] = await Promise.all([readCounters(env, "dl:"), readCounters(env, "inst:")]);
return Response.json({ downloads, installs }, { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } });
}
if (request.method !== "POST") return emptyResponse(405);
const contentLength = Number(request.headers.get("Content-Length") ?? "0");
if (contentLength > INSTALL_BODY_LIMIT_BYTES) return emptyResponse(413);
let payload: unknown;
try {
payload = JSON.parse(await request.text()) as unknown;
} catch {
return emptyResponse(400);
}
const { id, version } = (payload ?? {}) as Record<string, unknown>;
if (typeof id !== "string" || !PLUGIN_ID_PATTERN.test(id)) return emptyResponse(400);
if (typeof version !== "string" || !VERSION_PATTERN.test(version)) return emptyResponse(400);
await increment(env, `inst:${id}:${version}`);
return emptyResponse(204);
}
export default {
async fetch(request: Request, env: Env, ctx: { waitUntil(promise: Promise<unknown>): void }): Promise<Response> {
const url = new URL(request.url);
if (url.hostname === "dl.dbxio.com") {
if (request.method === "GET") {
const download = url.pathname.match(DOWNLOAD_PATTERN);
if (download) ctx.waitUntil(increment(env, `dl:${download[1]}:${download[2]}`));
}
return fetch(request);
}
return handleInstallBeacon(request, env);
},
};
+15
View File
@@ -0,0 +1,15 @@
{
"name": "dbx-plugin-stats",
"compatibility_date": "2026-05-07",
"main": "worker.ts",
"routes": [
{ "pattern": "dl.dbxio.com/plugins/*", "zone_name": "dbxio.com" },
{ "pattern": "dbxio.com/api/plugins/*", "zone_name": "dbxio.com" }
],
"kv_namespaces": [
{
"binding": "PLUGIN_STATS",
"id": "483dcc36cd3c4da5af5cdbb4532166f2"
}
]
}