Skip to content

Commit 0a4bfa0

Browse files
committed
feat: track dither-kit installs via a proxied /r registry route
Add a tracked `/r/{name}.json` route that proxies the dither-kit repo, rewrites @dither-kit deps to self-referencing /r URLs (so a bare install stays zero-config), and fires a Databuddy server event per fetch (core ~= total installs). Lead the docs install with the tracked URL; GitHub shorthand stays as an untracked alternative. Adds DATABUDDY_API_KEY env (optional) and restores the /login redirect search param dropped earlier.
1 parent 9055740 commit 0a4bfa0

7 files changed

Lines changed: 184 additions & 19 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ UNKEY_ROOT_KEY=
3535
# [optional] Autumn billing.
3636
AUTUMN_SECRET_KEY=
3737

38+
# [optional] Databuddy server API key (dbdy_...) — powers registry install
39+
# tracking on the /r/{name}.json route. Install counting is disabled if unset.
40+
DATABUDDY_API_KEY=
41+
3842
# [optional] OpenRouter — powers AI chat. Chat features are disabled if unset.
3943
OPENROUTER_API_KEY=
4044
# [optional] Model overrides. Defaults are used when unset.

apps/web/src/components/dither-kit-docs/content.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import type {
1111
* The primitives live in their own repo; only this lander stays in Tripwire. */
1212
export const REPO = "Boring-Software-Inc/dither-kit"
1313

14-
/** Namespace registry URL — the dither-kit repo's `r/` served over GitHub raw,
15-
* for the optional `@dither-kit` components.json path. */
16-
export const REGISTRY_URL =
17-
"https://raw.githubusercontent.com/Boring-Software-Inc/dither-kit/main/r/{name}.json"
14+
/** Tracked registry endpoint — Tripwire proxies the dither-kit repo and counts
15+
* each fetch as an install. Leading installs with this URL is what makes the
16+
* numbers real (the raw-GitHub shorthand can't be measured). Deps are rewritten
17+
* to self-referencing `/r/*` URLs, so a bare install stays zero-config. */
18+
export const REGISTRY_BASE = "https://tripwire.sh/r"
19+
export const regItem = (item: string): string => `${REGISTRY_BASE}/${item}.json`
20+
21+
/** Namespace registry URL, pointed at the tracked endpoint, for the optional
22+
* `@dither-kit` components.json path. */
23+
export const REGISTRY_URL = `${REGISTRY_BASE}/{name}.json`
1824

1925
/* ------------------------------------------------------- package manager */
2026

@@ -38,7 +44,7 @@ export const addCmd = (pm: Pm, item: string): string =>
3844
export const SETUP_PROMPT = `Set up dither-kit — composable dithered charts for shadcn/ui — in this project.
3945
4046
Install with the shadcn CLI (this also pulls the shared \`core\` engine + its deps like motion and d3):
41-
npx shadcn@latest add ${REPO}/area-chart
47+
npx shadcn@latest add ${regItem("area-chart")}
4248
4349
Files land in components/dither-kit/. The API is recharts-style / children-as-config — a data array plus a config object that maps each series to a label and colour:
4450
@@ -55,7 +61,7 @@ Files land in components/dither-kit/. The API is recharts-style / children-as-co
5561
<Area dataKey="desktop" variant="gradient" />
5662
</AreaChart>
5763
58-
Other charts install the same way: bar-chart, pie-chart, radar-chart (or ${REPO}/dither-kit for all of them).
64+
Other charts install the same way: bar-chart, pie-chart, radar-chart (or ${regItem("dither-kit")} for all of them).
5965
- variant: gradient | dotted | hatched | solid
6066
- color: green blue purple pink orange red grey
6167
- bloom: off | low | high | aura

apps/web/src/components/dither-kit-docs/sections.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
pieCode,
2929
pieConfig,
3030
pieData,
31+
regItem,
3132
REGISTRY_URL,
3233
REPO,
3334
type Pm,
@@ -188,9 +189,9 @@ export function InstallSection({
188189
automatically
189190
</span>
190191
<div className="flex flex-col gap-1.5">
191-
<CopyLine text={addCmd(pm, `${REPO}/area-chart`)} />
192-
<CopyLine text={addCmd(pm, `${REPO}/pie-chart`)} />
193-
<CopyLine text={addCmd(pm, `${REPO}/dither-kit`)} />
192+
<CopyLine text={addCmd(pm, regItem("area-chart"))} />
193+
<CopyLine text={addCmd(pm, regItem("pie-chart"))} />
194+
<CopyLine text={addCmd(pm, regItem("dither-kit"))} />
194195
</div>
195196
</div>
196197

@@ -210,10 +211,11 @@ export function InstallSection({
210211
<p className="font-mono text-[11px] leading-relaxed text-muted-foreground">
211212
also available: <span className="text-foreground">bar-chart</span>,{" "}
212213
<span className="text-foreground">radar-chart</span>, and{" "}
213-
<span className="text-foreground">core</span>.{" "}
214-
<span className="text-foreground">{REPO}/dither-kit</span> grabs
215-
everything. files land in{" "}
216-
<span className="text-foreground">components/dither-kit/</span>.
214+
<span className="text-foreground">core</span> — swap the last path
215+
segment. files land in{" "}
216+
<span className="text-foreground">components/dither-kit/</span>. prefer
217+
GitHub? <span className="text-foreground">{REPO}/area-chart</span> works
218+
too.
217219
</p>
218220

219221
<details className="group flex flex-col gap-2">
@@ -264,7 +266,7 @@ export function ChartGallery({
264266

265267
<Showcase
266268
title="area"
267-
install={addCmd(pm, `${REPO}/area-chart`)}
269+
install={addCmd(pm, regItem("area-chart"))}
268270
code={areaCode(tweaks)}
269271
toolbar={<ReplayButton onClick={() => onReplay("area")} />}
270272
>
@@ -287,7 +289,7 @@ export function ChartGallery({
287289

288290
<Showcase
289291
title="bar"
290-
install={addCmd(pm, `${REPO}/bar-chart`)}
292+
install={addCmd(pm, regItem("bar-chart"))}
291293
code={barCode(tweaks)}
292294
toolbar={<ReplayButton onClick={() => onReplay("bar")} />}
293295
>
@@ -310,7 +312,7 @@ export function ChartGallery({
310312

311313
<Showcase
312314
title="line"
313-
install={addCmd(pm, `${REPO}/area-chart`)}
315+
install={addCmd(pm, regItem("area-chart"))}
314316
code={lineCode(tweaks)}
315317
toolbar={<ReplayButton onClick={() => onReplay("line")} />}
316318
>
@@ -333,7 +335,7 @@ export function ChartGallery({
333335
<div className="grid gap-14 lg:grid-cols-2 lg:gap-8">
334336
<Showcase
335337
title="pie"
336-
install={addCmd(pm, `${REPO}/pie-chart`)}
338+
install={addCmd(pm, regItem("pie-chart"))}
337339
code={pieCode(tweaks)}
338340
tall
339341
toolbar={<ReplayButton onClick={() => onReplay("pie")} />}
@@ -356,7 +358,7 @@ export function ChartGallery({
356358

357359
<Showcase
358360
title="radar"
359-
install={addCmd(pm, `${REPO}/radar-chart`)}
361+
install={addCmd(pm, regItem("radar-chart"))}
360362
code={radarCode(tweaks)}
361363
tall
362364
toolbar={<ReplayButton onClick={() => onReplay("radar")} />}

apps/web/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Route as AppRouteImport } from './routes/_app'
1616
import { Route as AdminRouteImport } from './routes/_admin'
1717
import { Route as OnboardingRouteRouteImport } from './routes/onboarding/route'
1818
import { Route as IndexRouteImport } from './routes/index'
19+
import { Route as RNameRouteImport } from './routes/r/$name'
1920
import { Route as OauthConsentRouteImport } from './routes/oauth/consent'
2021
import { Route as ApiMcpRouteImport } from './routes/api/mcp'
2122
import { Route as ApiInngestRouteImport } from './routes/api/inngest'
@@ -114,6 +115,11 @@ const IndexRoute = IndexRouteImport.update({
114115
path: '/',
115116
getParentRoute: () => rootRouteImport,
116117
} as any)
118+
const RNameRoute = RNameRouteImport.update({
119+
id: '/r/$name',
120+
path: '/r/$name',
121+
getParentRoute: () => rootRouteImport,
122+
} as any)
117123
const OauthConsentRoute = OauthConsentRouteImport.update({
118124
id: '/oauth/consent',
119125
path: '/oauth/consent',
@@ -471,6 +477,7 @@ export interface FileRoutesByFullPath {
471477
'/api/inngest': typeof ApiInngestRoute
472478
'/api/mcp': typeof ApiMcpRoute
473479
'/oauth/consent': typeof OauthConsentRoute
480+
'/r/$name': typeof RNameRoute
474481
'/$orgHandle/rules': typeof AppOrgHandleRulesRouteRouteWithChildren
475482
'/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute
476483
'/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute
@@ -542,6 +549,7 @@ export interface FileRoutesByTo {
542549
'/api/inngest': typeof ApiInngestRoute
543550
'/api/mcp': typeof ApiMcpRoute
544551
'/oauth/consent': typeof OauthConsentRoute
552+
'/r/$name': typeof RNameRoute
545553
'/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute
546554
'/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute
547555
'/admin/reputation': typeof AdminAdminReputationRoute
@@ -615,6 +623,7 @@ export interface FileRoutesById {
615623
'/api/inngest': typeof ApiInngestRoute
616624
'/api/mcp': typeof ApiMcpRoute
617625
'/oauth/consent': typeof OauthConsentRoute
626+
'/r/$name': typeof RNameRoute
618627
'/_app/$orgHandle/rules': typeof AppOrgHandleRulesRouteRouteWithChildren
619628
'/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute
620629
'/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute
@@ -688,6 +697,7 @@ export interface FileRouteTypes {
688697
| '/api/inngest'
689698
| '/api/mcp'
690699
| '/oauth/consent'
700+
| '/r/$name'
691701
| '/$orgHandle/rules'
692702
| '/.well-known/oauth-authorization-server/$'
693703
| '/.well-known/oauth-protected-resource/$'
@@ -759,6 +769,7 @@ export interface FileRouteTypes {
759769
| '/api/inngest'
760770
| '/api/mcp'
761771
| '/oauth/consent'
772+
| '/r/$name'
762773
| '/.well-known/oauth-authorization-server/$'
763774
| '/.well-known/oauth-protected-resource/$'
764775
| '/admin/reputation'
@@ -831,6 +842,7 @@ export interface FileRouteTypes {
831842
| '/api/inngest'
832843
| '/api/mcp'
833844
| '/oauth/consent'
845+
| '/r/$name'
834846
| '/_app/$orgHandle/rules'
835847
| '/.well-known/oauth-authorization-server/$'
836848
| '/.well-known/oauth-protected-resource/$'
@@ -897,6 +909,7 @@ export interface RootRouteChildren {
897909
ApiInngestRoute: typeof ApiInngestRoute
898910
ApiMcpRoute: typeof ApiMcpRoute
899911
OauthConsentRoute: typeof OauthConsentRoute
912+
RNameRoute: typeof RNameRoute
900913
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
901914
ApiGithubCallbackRoute: typeof ApiGithubCallbackRoute
902915
ApiGithubInstallRoute: typeof ApiGithubInstallRoute
@@ -960,6 +973,13 @@ declare module '@tanstack/react-router' {
960973
preLoaderRoute: typeof IndexRouteImport
961974
parentRoute: typeof rootRouteImport
962975
}
976+
'/r/$name': {
977+
id: '/r/$name'
978+
path: '/r/$name'
979+
fullPath: '/r/$name'
980+
preLoaderRoute: typeof RNameRouteImport
981+
parentRoute: typeof rootRouteImport
982+
}
963983
'/oauth/consent': {
964984
id: '/oauth/consent'
965985
path: '/oauth/consent'
@@ -1607,6 +1627,7 @@ const rootRouteChildren: RootRouteChildren = {
16071627
ApiInngestRoute: ApiInngestRoute,
16081628
ApiMcpRoute: ApiMcpRoute,
16091629
OauthConsentRoute: OauthConsentRoute,
1630+
RNameRoute: RNameRoute,
16101631
ApiAuthSplatRoute: ApiAuthSplatRoute,
16111632
ApiGithubCallbackRoute: ApiGithubCallbackRoute,
16121633
ApiGithubInstallRoute: ApiGithubInstallRoute,

apps/web/src/routes/login.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import {
66
import { buildSeo, formatPageTitle } from "#/lib/seo"
77

88
export const Route = createFileRoute("/login")({
9-
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
9+
validateSearch: (
10+
search: Record<string, unknown>
11+
): { error?: string; redirect?: string } => ({
1012
error: typeof search.error === "string" ? search.error : undefined,
13+
redirect:
14+
typeof search.redirect === "string" ? search.redirect : undefined,
1115
}),
1216
component: LoginPage,
1317
pendingComponent: LoginPageSkeleton,

apps/web/src/routes/r/$name.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { createHash } from "node:crypto"
2+
import { Databuddy } from "@databuddy/sdk/node"
3+
import { createFileRoute } from "@tanstack/react-router"
4+
import { env } from "@tripwire/env/server"
5+
6+
/**
7+
* Tracked Dither Kit registry endpoint. Serves each registry item's JSON from
8+
* the canonical repo and counts the fetch as an install signal in Databuddy —
9+
* the only way to measure installs, since the raw-GitHub shorthand is a black
10+
* box. Deps are rewritten to self-referencing `/r/*` URLs so a bare
11+
* `shadcn add https://tripwire.sh/r/dither-kit.json` install is zero-config and
12+
* every fetch (including `core`) flows through here.
13+
*/
14+
15+
const REPO_RAW =
16+
"https://raw.githubusercontent.com/Boring-Software-Inc/dither-kit/main/r"
17+
const WEBSITE_ID = "09661145-7249-45d9-a9e3-f1a93e9c7266"
18+
const NS = "@dither-kit/"
19+
const ITEMS = new Set([
20+
"registry",
21+
"core",
22+
"area-chart",
23+
"bar-chart",
24+
"pie-chart",
25+
"radar-chart",
26+
"dither-kit",
27+
])
28+
29+
const db = env.DATABUDDY_API_KEY
30+
? new Databuddy({
31+
apiKey: env.DATABUDDY_API_KEY,
32+
websiteId: WEBSITE_ID,
33+
source: "registry",
34+
})
35+
: null
36+
37+
// Warm in-memory cache of the upstream JSON so we don't hit GitHub on every
38+
// install; the response itself is `no-store` so each install still reaches us.
39+
const cache = new Map<string, { raw: string; exp: number }>()
40+
41+
async function upstreamJson(name: string): Promise<string | null> {
42+
const hit = cache.get(name)
43+
if (hit && hit.exp > Date.now()) return hit.raw
44+
const res = await fetch(`${REPO_RAW}/${name}.json`)
45+
if (!res.ok) return null
46+
const raw = await res.text()
47+
cache.set(name, { raw, exp: Date.now() + 5 * 60_000 })
48+
return raw
49+
}
50+
51+
/** Rewrite `@dither-kit/<x>` registryDependencies to this host's `/r/<x>.json`. */
52+
function selfHost(raw: string, origin: string): string {
53+
const rewrite = (dep: string) =>
54+
dep.startsWith(NS) ? `${origin}/r/${dep.slice(NS.length)}.json` : dep
55+
const obj = JSON.parse(raw)
56+
if (Array.isArray(obj.registryDependencies)) {
57+
obj.registryDependencies = obj.registryDependencies.map(rewrite)
58+
}
59+
if (Array.isArray(obj.items)) {
60+
obj.items = obj.items.map((it: { registryDependencies?: string[] }) => ({
61+
...it,
62+
registryDependencies: (it.registryDependencies ?? []).map(rewrite),
63+
}))
64+
}
65+
return JSON.stringify(obj)
66+
}
67+
68+
function anonId(request: Request): string {
69+
const ip =
70+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
71+
request.headers.get("x-real-ip") ??
72+
"unknown"
73+
const ua = request.headers.get("user-agent") ?? ""
74+
return createHash("sha256").update(`${ip}|${ua}`).digest("hex").slice(0, 32)
75+
}
76+
77+
async function handler({ request }: { request: Request }) {
78+
const url = new URL(request.url)
79+
const name = url.pathname.split("/").pop()?.replace(/\.json$/, "") ?? ""
80+
81+
if (!ITEMS.has(name)) {
82+
return new Response(JSON.stringify({ error: "unknown registry item" }), {
83+
status: 404,
84+
headers: { "Content-Type": "application/json" },
85+
})
86+
}
87+
88+
const raw = await upstreamJson(name)
89+
if (raw === null) {
90+
return new Response(JSON.stringify({ error: "registry item unavailable" }), {
91+
status: 502,
92+
headers: { "Content-Type": "application/json" },
93+
})
94+
}
95+
96+
// Fire-and-forget — never block the install. Skip the index (`registry`);
97+
// per-item hits are the signal (core ≈ total installs, others = popularity).
98+
if (db && name !== "registry") {
99+
db.track({
100+
name: "registry_install",
101+
anonymousId: anonId(request),
102+
properties: {
103+
item: name,
104+
isCore: name === "core",
105+
ua: request.headers.get("user-agent") ?? "",
106+
},
107+
}).catch(() => {})
108+
}
109+
110+
return new Response(selfHost(raw, url.origin), {
111+
status: 200,
112+
headers: {
113+
"Content-Type": "application/json",
114+
// No shared cache, so every install reaches us and is counted.
115+
"Cache-Control": "no-store",
116+
"Access-Control-Allow-Origin": "*",
117+
},
118+
})
119+
}
120+
121+
export const Route = createFileRoute("/r/$name")({
122+
server: {
123+
handlers: {
124+
GET: handler,
125+
},
126+
},
127+
})

packages/env/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const env = createEnv({
8080
message: "DATABASE_URL must be set in production",
8181
}),
8282
UNKEY_ROOT_KEY: z.string().min(1).optional(),
83+
DATABUDDY_API_KEY: z.string().min(1).optional(),
8384
OPENROUTER_API_KEY: z.string().min(1).optional(),
8485
AUTUMN_SECRET_KEY: z.string().min(1).optional(),
8586
BETTER_AUTH_API_KEY: z.string().min(1).optional(),

0 commit comments

Comments
 (0)