Skip to content

Commit dfef742

Browse files
committed
feat: load sizes in pages
1 parent d2628db commit dfef742

File tree

2 files changed

+128
-21
lines changed

2 files changed

+128
-21
lines changed

app/pages/package-timeline/[[org]]/[packageName].vue

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
TimelineResponse,
66
TimelineVersion,
77
} from '~~/server/api/registry/timeline/[...pkg].get'
8+
import type { TimelineSizeResponse } from '~~/server/api/registry/timeline/sizes/[...pkg].get'
89
910
definePageMeta({
1011
name: 'timeline',
@@ -81,13 +82,15 @@ watch(
8182
)
8283
8384
async function loadMore() {
84-
if (loadingMore.value || sizesLoading.value) return
85+
if (loadingMore.value) return
8586
loadingMore.value = true
8687
loadError.value = false
8788
try {
88-
const data = await fetchTimeline(timelineEntries.value.length)
89+
const offset = timelineEntries.value.length
90+
const data = await fetchTimeline(offset)
8991
timelineEntries.value = [...timelineEntries.value, ...data.versions]
9092
totalVersions.value = data.total
93+
fetchSizes(offset)
9194
} catch {
9295
loadError.value = true
9396
} finally {
@@ -99,39 +102,40 @@ const SIZE_INCREASE_THRESHOLD = 0.25
99102
const DEP_INCREASE_THRESHOLD = 5
100103
const NO_LICENSE_VALUES = new Set(['', 'UNLICENSED'])
101104
102-
const sizeCache = shallowReactive(new Map<string, InstallSizeResult>())
103-
const fetchingVersions = shallowReactive(new Set<string>())
104-
105-
const sizesLoading = computed(() => fetchingVersions.size > 0)
105+
const sizeCache = shallowReactive(new Map<string, { totalSize: number; dependencyCount: number }>())
106+
const sizeFetchesInFlight = ref(0)
107+
const sizesLoading = computed(() => sizeFetchesInFlight.value > 0)
106108
107109
function sizeKey(ver: string) {
108110
return `${packageName.value}@${ver}`
109111
}
110112
111-
async function fetchSize(ver: string) {
112-
const key = sizeKey(ver)
113-
if (sizeCache.has(key) || fetchingVersions.has(key)) return
114-
fetchingVersions.add(key)
113+
async function fetchSizes(offset: number) {
114+
sizeFetchesInFlight.value++
115115
try {
116-
const data = await $fetch<InstallSizeResult>(
117-
`/api/registry/install-size/${packageName.value}/v/${ver}`,
116+
const data = await $fetch<TimelineSizeResponse>(
117+
`/api/registry/timeline/sizes/${packageName.value}`,
118+
{ query: { offset, limit: PAGE_SIZE } },
118119
)
119-
sizeCache.set(key, data)
120+
for (const entry of data.sizes) {
121+
sizeCache.set(sizeKey(entry.version), {
122+
totalSize: entry.totalSize,
123+
dependencyCount: entry.dependencyCount,
124+
})
125+
}
120126
} catch {
121127
// silently skip - size data is best-effort
122128
} finally {
123-
fetchingVersions.delete(key)
129+
sizeFetchesInFlight.value--
124130
}
125131
}
126132
127-
// Fetch sizes for visible versions
133+
// Fetch sizes for the initial page
128134
if (import.meta.client) {
129135
watch(
130-
timelineEntries,
131-
entries => {
132-
for (const entry of entries) {
133-
fetchSize(entry.version)
134-
}
136+
initialTimeline,
137+
() => {
138+
fetchSizes(0)
135139
},
136140
{ immediate: true },
137141
)
@@ -322,6 +326,11 @@ useSeoMeta({
322326
/>
323327

324328
<div class="container w-full py-8">
329+
<!-- Sizes loading indicator -->
330+
<div v-if="sizesLoading" class="h-0.5 mb-4 rounded-full bg-bg-muted overflow-hidden">
331+
<div class="h-full w-1/3 bg-accent rounded-full animate-indeterminate" />
332+
</div>
333+
325334
<!-- Timeline -->
326335
<ol v-if="timelineEntries.length" class="relative border-s border-border ms-4">
327336
<li v-for="entry in timelineEntries" :key="entry.version" class="mb-6 ms-6">
@@ -394,7 +403,7 @@ useSeoMeta({
394403
<button
395404
type="button"
396405
class="text-sm text-accent hover:text-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
397-
:disabled="loadingMore || sizesLoading"
406+
:disabled="loadingMore"
398407
@click="loadMore"
399408
>
400409
{{ $t('package.timeline.load_more') }}
@@ -418,3 +427,18 @@ useSeoMeta({
418427
</div>
419428
</main>
420429
</template>
430+
431+
<style scoped>
432+
@keyframes indeterminate {
433+
0% {
434+
translate: -100%;
435+
}
436+
100% {
437+
translate: 400%;
438+
}
439+
}
440+
441+
.animate-indeterminate {
442+
animation: indeterminate 1.5s ease-in-out infinite;
443+
}
444+
</style>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const DEFAULT_LIMIT = 25
2+
3+
export interface TimelineSizeEntry {
4+
version: string
5+
totalSize: number
6+
dependencyCount: number
7+
}
8+
9+
export interface TimelineSizeResponse {
10+
sizes: TimelineSizeEntry[]
11+
}
12+
13+
/**
14+
* Returns install sizes for a page of timeline versions.
15+
*
16+
* Uses the same offset/limit and sort order as the timeline endpoint so the
17+
* client can pair results by position.
18+
*
19+
* Examples:
20+
* - /api/registry/timeline/sizes/packageName?offset=0&limit=25
21+
* - /api/registry/timeline/sizes/@scope/packageName?offset=0&limit=25
22+
*/
23+
export default defineCachedEventHandler(
24+
async event => {
25+
const pkgParam = getRouterParam(event, 'pkg')
26+
if (!pkgParam) {
27+
throw createError({ statusCode: 404, message: 'Package name is required' })
28+
}
29+
30+
let packageName: string
31+
try {
32+
packageName = decodeURIComponent(pkgParam)
33+
} catch {
34+
throw createError({ statusCode: 400, message: 'Invalid package name encoding' })
35+
}
36+
37+
const query = getQuery(event)
38+
const offset = Math.max(0, Number(query.offset) || 0)
39+
const limit = Math.max(1, Math.min(100, Number(query.limit) || DEFAULT_LIMIT))
40+
41+
try {
42+
const packument = await fetchNpmPackage(packageName)
43+
44+
const allVersions = Object.keys(packument.versions)
45+
.filter(v => packument.time[v])
46+
.sort((a, b) => Date.parse(packument.time[b]!) - Date.parse(packument.time[a]!))
47+
48+
const pageVersions = allVersions.slice(offset, offset + limit)
49+
50+
const results = await Promise.allSettled(
51+
pageVersions.map(v => calculateInstallSize(packageName, v)),
52+
)
53+
54+
const sizes: TimelineSizeEntry[] = []
55+
for (const result of results) {
56+
if (result.status === 'fulfilled' && result.value.totalSize > 0) {
57+
sizes.push({
58+
version: result.value.version,
59+
totalSize: result.value.totalSize,
60+
dependencyCount: result.value.dependencyCount,
61+
})
62+
}
63+
}
64+
65+
return { sizes } satisfies TimelineSizeResponse
66+
} catch (error: unknown) {
67+
handleApiError(error, {
68+
statusCode: 502,
69+
message: `Failed to fetch install sizes for ${packageName}`,
70+
})
71+
}
72+
},
73+
{
74+
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
75+
swr: true,
76+
getKey: event => {
77+
const query = getQuery(event)
78+
const offset = Math.max(0, Number(query.offset) || 0)
79+
const limit = Math.max(1, Math.min(100, Number(query.limit) || DEFAULT_LIMIT))
80+
return `install-size-timeline:v1:${getRouterParam(event, 'pkg')}:${offset}:${limit}`
81+
},
82+
},
83+
)

0 commit comments

Comments
 (0)