mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(plugins): count marketplace downloads and installs
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user