Files
freno-dev/src/lib/download-asset.ts
Michael Freno a524f23be2 downloads: track every door and surface them on /analytics
The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
2026-10-22 12:59:48 -04:00

101 lines
3.2 KiB
TypeScript

/**
* Pure, testable helper for triggering a signed-S3 download via the tRPC
* `downloads.getDownloadUrl` endpoint.
*
* Extracted so the Gaze landing page's download button — and any
* other subdomain landing page that needs the same flow (InputHalo, Lineage,
* …) — can share a single code path AND be unit-tested without importing
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
*
* The component layer supplies the concrete `api` (dynamic-imported at click
* time) and the `redirect` sink (defaults to `window.location.href = url`).
* Tests inject a fake `api` and a capture sink.
*/
/** Canonical analytics path for one download event. One label per asset, so a
* click on a landing page and a Sparkle update of the same app land in the
* same bucket on the analytics page. */
export const DOWNLOAD_PATH_PREFIX = "/api/downloads/";
export function downloadPath(asset: string): string {
return `${DOWNLOAD_PATH_PREFIX}${asset}`;
}
/**
* Group download rows by the asset named in their canonical path, and total
* them. Split out from the query so the labelling has a unit test without a
* database — the writer and the reader share `DOWNLOAD_PATH_PREFIX`, so the
* two cannot drift apart.
*/
export function summariseDownloads(
rows: Array<{ path: string; count: number }>
): { total: number; byAsset: Array<{ asset: string; count: number }> } {
const byAsset = rows.map((row) => ({
asset: row.path.slice(DOWNLOAD_PATH_PREFIX.length),
count: row.count
}));
return {
total: byAsset.reduce((sum, row) => sum + row.count, 0),
byAsset
};
}
/** Structural shape of the tRPC downloads proxy this helper depends on. */
export interface DownloadApi {
downloads: {
getDownloadUrl: {
query: (input: { asset_name: string }) => Promise<{
downloadURL: string;
}>;
};
};
}
/** A `(url) => void` sink the helper calls with the signed S3 URL. */
export type DownloadRedirect = (url: string) => void;
export interface DownloadAssetOptions {
/** tRPC proxy (or fake) exposing `downloads.getDownloadUrl.query`. */
api: DownloadApi;
/** Asset key known to the downloads router (e.g. `"gaze"`). */
assetName: string;
/** Called with the signed URL. Defaults to `window.location.href = url`. */
redirect?: DownloadRedirect;
/** Invoked on failure; defaults to a no-op (component shows its own UI). */
onError?: (error: unknown) => void;
}
/** Default redirect sink — browser navigation to the signed S3 URL. */
const defaultRedirect: DownloadRedirect = (url) => {
if (typeof window !== "undefined") {
window.location.href = url;
}
};
/**
* Resolve the latest signed download URL for `assetName` and redirect the
* browser to it. Never throws — failures are routed to `onError`.
*
* @example
* ```ts
* import("~/lib/api").then(({ api }) => {
* downloadAsset({ api, assetName: "gaze" });
* });
* ```
*/
export async function downloadAsset(
options: DownloadAssetOptions
): Promise<void> {
const { api, assetName, redirect = defaultRedirect, onError } = options;
try {
const data = await api.downloads.getDownloadUrl.query({
asset_name: assetName
});
redirect(data.downloadURL);
} catch (error) {
if (onError) onError(error);
}
}