Skip to content

Commit 0ab231f

Browse files
jaysin586claude
andcommitted
feat(server): add runtime package stats helper
Every Humanspeak docs site documents a published npm package and wants live size + version numbers in its UI. Baking those values in at deploy time means the docs go stale every time the library publishes without a paired docs change. This helper breaks that coupling by fetching `{ name, version, tarballBytes, unpackedBytes, updatedAt }` from the npm registry at request time, with an in-memory cache and HTTP `cache-control` headers so Cloudflare's edge holds the rendered response. Exported via a new `@humanspeak/docs-kit/server` subpath so consumer client bundles never pull in server-only code. Two entry points: - `fetchPackageStats(name, version, opts?)` — low-level: returns `PackageStats | null`. Use for one-off lookups (other surfaces, scripts). - `createPackageStatsLoad({ pkg, ttlMs?, devFallback?, dev? })` — factory that returns a SvelteKit `PageServerLoad`. Consumer wires it in two lines: ```ts import { createPackageStatsLoad } from '@humanspeak/docs-kit/server' import rootPkg from '../../../package.json' export const prerender = false export const load = createPackageStatsLoad({ pkg: rootPkg }) ``` Registry failures fall back to the workspace `package.json` version with `null` sizes (rendered as `—`); in dev, optional `devFallback` substitutes representative numbers so the design previews correctly without a network round-trip. Production never displays incorrect values — only missing ones. IMPORTANT: routes using `createPackageStatsLoad` must NOT set `export const prerender = true` — prerendering would freeze the stats at build time and reintroduce the staleness problem the helper exists to solve. Inline comments in the source flag this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b07d2e commit 0ab231f

3 files changed

Lines changed: 200 additions & 0 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"types": "./dist/hooks/index.d.ts",
2525
"default": "./dist/hooks/index.js"
2626
},
27+
"./server": {
28+
"types": "./dist/server/index.d.ts",
29+
"default": "./dist/server/index.js"
30+
},
2731
"./scripts/fetch-github-stats": {
2832
"types": "./dist/scripts/fetch-github-stats.d.ts",
2933
"default": "./dist/scripts/fetch-github-stats.js"

src/lib/server/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Server-only utilities for SvelteKit `+page.server.ts` / `+layout.server.ts`
3+
* files. Importing from this subpath signals to Vite that the module
4+
* must not be bundled into client code.
5+
*/
6+
7+
export {
8+
createPackageStatsLoad,
9+
fetchPackageStats,
10+
type FetchPackageStatsOptions,
11+
type PackageStats,
12+
type PackageStatsLoadOptions
13+
} from './package-stats.js'

src/lib/server/package-stats.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { ServerLoadEvent } from '@sveltejs/kit'
2+
3+
/**
4+
* Runtime package stats helper — fetches `{ name, version, tarballBytes,
5+
* unpackedBytes, updatedAt }` from the npm registry at request time, with
6+
* an in-memory cache and HTTP `cache-control` headers so Cloudflare's
7+
* edge holds the response.
8+
*
9+
* Why this exists:
10+
*
11+
* Every Humanspeak docs site documents a published npm package and wants
12+
* to surface live size + version stats in its UI. Baking those values in
13+
* at deploy time means the docs go stale every time the library
14+
* publishes without a paired docs change. Fetching them at request time
15+
* with a sensible cache breaks that coupling entirely.
16+
*
17+
* Typical consumer wiring:
18+
*
19+
* ```ts
20+
* // docs/src/routes/+page.server.ts
21+
* import rootPkg from '../../../package.json'
22+
* import { createPackageStatsLoad } from '@humanspeak/docs-kit/server'
23+
*
24+
* export const prerender = false
25+
* export const load = createPackageStatsLoad({
26+
* pkg: rootPkg,
27+
* devFallback: { tarballBytes: 81458, unpackedBytes: 324332 }
28+
* })
29+
* ```
30+
*
31+
* IMPORTANT: do not add `export const prerender = true` to a route that
32+
* uses this loader — prerendering would freeze the stats at build time
33+
* and reintroduce the staleness problem this helper exists to solve.
34+
*/
35+
36+
export interface PackageStats {
37+
name: string
38+
version: string
39+
/** Packed tarball size in bytes (gzipped). `null` when registry was unreachable. */
40+
tarballBytes: number | null
41+
/** Unpacked install size in bytes. `null` when registry was unreachable. */
42+
unpackedBytes: number | null
43+
/** ISO timestamp of the last successful registry fetch. */
44+
updatedAt: string
45+
}
46+
47+
interface RegistryDist {
48+
fileCount?: number
49+
unpackedSize?: number
50+
tarball?: string
51+
}
52+
53+
interface RegistryVersionRecord {
54+
name?: string
55+
version?: string
56+
dist?: RegistryDist
57+
}
58+
59+
const DEFAULT_TIMEOUT_MS = 4000
60+
61+
const fetchWithTimeout = async (url: string, timeoutMs: number): Promise<Response> => {
62+
const ctrl = new AbortController()
63+
const t = setTimeout(() => ctrl.abort(), timeoutMs)
64+
try {
65+
return await fetch(url, { signal: ctrl.signal })
66+
} finally {
67+
clearTimeout(t)
68+
}
69+
}
70+
71+
export interface FetchPackageStatsOptions {
72+
/** Network timeout in milliseconds. Defaults to 4000ms. */
73+
timeoutMs?: number
74+
/** Alternative npm registry base URL (mirrors, private registries). */
75+
registryBase?: string
76+
}
77+
78+
/**
79+
* Hits the npm registry for `<name>@<version>` and returns the size
80+
* metadata, or `null` on any error (network failure, 404, timeout, malformed
81+
* response). Safe to call from a Cloudflare Worker or any other edge
82+
* runtime that exposes `fetch`.
83+
*/
84+
export const fetchPackageStats = async (
85+
name: string,
86+
version: string,
87+
opts: FetchPackageStatsOptions = {}
88+
): Promise<PackageStats | null> => {
89+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
90+
const registryBase = opts.registryBase ?? 'https://registry.npmjs.org'
91+
try {
92+
const url = `${registryBase}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`
93+
const res = await fetchWithTimeout(url, timeoutMs)
94+
if (!res.ok) return null
95+
const record = (await res.json()) as RegistryVersionRecord
96+
const dist = record.dist
97+
let tarballBytes: number | null = null
98+
if (dist?.tarball) {
99+
try {
100+
const head = await fetchWithTimeout(dist.tarball, timeoutMs)
101+
const len = head.headers.get('content-length')
102+
if (head.ok && len) tarballBytes = Number(len)
103+
} catch {
104+
/* tarball HEAD is optional — fall back to null */
105+
}
106+
}
107+
return {
108+
name,
109+
version,
110+
tarballBytes,
111+
unpackedBytes: dist?.unpackedSize ?? null,
112+
updatedAt: new Date().toISOString()
113+
}
114+
} catch {
115+
return null
116+
}
117+
}
118+
119+
export interface PackageStatsLoadOptions {
120+
/** Workspace `package.json` import (or any `{ name, version }` shape). */
121+
pkg: { name: string; version: string }
122+
/** Cache TTL in milliseconds. Defaults to 1 hour. */
123+
ttlMs?: number
124+
/** Network timeout in milliseconds. Defaults to 4000ms. */
125+
timeoutMs?: number
126+
/** Alternative npm registry base URL. */
127+
registryBase?: string
128+
/**
129+
* Values to substitute when the registry can't be reached AND we're
130+
* running in dev. Lets the design preview properly without a network
131+
* round-trip. In production, registry failures still surface as
132+
* `null` so we never display incorrect numbers.
133+
*/
134+
devFallback?: {
135+
tarballBytes: number | null
136+
unpackedBytes: number | null
137+
}
138+
/** True when running under `vite dev`. Pass `import { dev } from '$app/environment'`. */
139+
dev?: boolean
140+
}
141+
142+
interface CacheEntry {
143+
fetchedAt: number
144+
data: PackageStats
145+
}
146+
147+
/**
148+
* Builds a SvelteKit `PageServerLoad` (or `LayoutServerLoad`) that
149+
* resolves `{ packageStats }` to PageData. Memoised per-worker-instance
150+
* for `ttlMs`, and sets HTTP cache-control headers matching the same TTL
151+
* so the edge can share one rendered HTML response across requests.
152+
*/
153+
export const createPackageStatsLoad = (opts: PackageStatsLoadOptions) => {
154+
const ttlMs = opts.ttlMs ?? 60 * 60 * 1000
155+
156+
let memo: CacheEntry | null = null
157+
158+
const fallback = (): PackageStats => ({
159+
name: opts.pkg.name,
160+
version: opts.pkg.version,
161+
tarballBytes: opts.dev ? (opts.devFallback?.tarballBytes ?? null) : null,
162+
unpackedBytes: opts.dev ? (opts.devFallback?.unpackedBytes ?? null) : null,
163+
updatedAt: new Date(0).toISOString()
164+
})
165+
166+
return async (event: Pick<ServerLoadEvent, 'setHeaders'>) => {
167+
event.setHeaders({
168+
'cache-control': `public, max-age=${ttlMs / 1000}, s-maxage=${ttlMs / 1000}`
169+
})
170+
171+
if (memo && Date.now() - memo.fetchedAt < ttlMs) {
172+
return { packageStats: memo.data }
173+
}
174+
175+
const fresh = await fetchPackageStats(opts.pkg.name, opts.pkg.version, {
176+
timeoutMs: opts.timeoutMs,
177+
registryBase: opts.registryBase
178+
})
179+
const data = fresh ?? fallback()
180+
memo = { fetchedAt: Date.now(), data }
181+
return { packageStats: data }
182+
}
183+
}

0 commit comments

Comments
 (0)