Skip to content

Commit a97a7a1

Browse files
authored
refactor: separate npm composables (#827)
1 parent 8fae46c commit a97a7a1

22 files changed

+901
-879
lines changed

app/components/Org/MembersPanel.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import type { NewOperation } from '~/composables/useConnector'
3-
import { buildScopeTeam } from '~/utils/npm'
3+
import { buildScopeTeam } from '~/utils/npm/common'
44
55
const props = defineProps<{
66
orgName: string

app/components/Org/TeamsPanel.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import type { NewOperation } from '~/composables/useConnector'
3-
import { buildScopeTeam } from '~/utils/npm'
3+
import { buildScopeTeam } from '~/utils/npm/common'
44
55
const props = defineProps<{
66
orgName: string

app/components/Package/AccessControls.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import type { NewOperation } from '~/composables/useConnector'
3-
import { buildScopeTeam } from '~/utils/npm'
3+
import { buildScopeTeam } from '~/utils/npm/common'
44
55
const props = defineProps<{
66
packageName: string

app/components/Package/Dependencies.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
2-
import { useDependencyAnalysis } from '~/composables/useDependencyAnalysis'
32
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
3+
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
44
55
const props = defineProps<{
66
packageName: string

app/components/Package/InstallScripts.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
3+
24
const props = defineProps<{
35
packageName: string
46
installScripts: {

app/components/Package/Versions.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { PackageVersionInfo, SlimVersion } from '#shared/types'
33
import { compare } from 'semver'
44
import type { RouteLocationRaw } from 'vue-router'
5-
import { fetchAllPackageVersions } from '~/composables/useNpmRegistry'
5+
import { fetchAllPackageVersions } from '~/utils/npm/api'
66
import {
77
buildVersionToTagsMap,
88
filterExcludedTags,

app/components/VersionSelector.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
getVersionGroupLabel,
1010
isSameVersionGroup,
1111
} from '~/utils/versions'
12-
import { fetchAllPackageVersions } from '~/composables/useNpmRegistry'
12+
import { fetchAllPackageVersions } from '~/utils/npm/api'
1313
1414
const props = defineProps<{
1515
packageName: string
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type {
2+
Packument,
3+
NpmSearchResponse,
4+
NpmSearchResult,
5+
NpmDownloadCount,
6+
MinimalPackument,
7+
} from '#shared/types'
8+
import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common'
9+
10+
/**
11+
* Convert packument to search result format for display
12+
*/
13+
export function packumentToSearchResult(
14+
pkg: MinimalPackument,
15+
weeklyDownloads?: number,
16+
): NpmSearchResult {
17+
let latestVersion = ''
18+
if (pkg['dist-tags']) {
19+
latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || ''
20+
}
21+
const modified = pkg.time.modified || pkg.time[latestVersion] || ''
22+
23+
return {
24+
package: {
25+
name: pkg.name,
26+
version: latestVersion,
27+
description: pkg.description,
28+
keywords: pkg.keywords,
29+
date: pkg.time[latestVersion] || modified,
30+
links: {
31+
npm: `https://www.npmjs.com/package/${pkg.name}`,
32+
},
33+
maintainers: pkg.maintainers,
34+
},
35+
score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } },
36+
searchScore: 0,
37+
downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined,
38+
updated: pkg.time[latestVersion] || modified,
39+
}
40+
}
41+
42+
export interface NpmSearchOptions {
43+
/** Number of results to fetch */
44+
size?: number
45+
}
46+
47+
export const emptySearchResponse = {
48+
objects: [],
49+
total: 0,
50+
isStale: false,
51+
time: new Date().toISOString(),
52+
} satisfies NpmSearchResponse
53+
54+
export function useNpmSearch(
55+
query: MaybeRefOrGetter<string>,
56+
options: MaybeRefOrGetter<NpmSearchOptions> = {},
57+
) {
58+
const cachedFetch = useCachedFetch()
59+
// Client-side cache
60+
const cache = shallowRef<{
61+
query: string
62+
objects: NpmSearchResult[]
63+
total: number
64+
} | null>(null)
65+
66+
const isLoadingMore = shallowRef(false)
67+
68+
// Standard (non-incremental) search implementation
69+
let lastSearch: NpmSearchResponse | undefined = undefined
70+
71+
const asyncData = useLazyAsyncData(
72+
() => `search:incremental:${toValue(query)}`,
73+
async (_nuxtApp, { signal }) => {
74+
const q = toValue(query)
75+
76+
if (!q.trim()) {
77+
return emptySearchResponse
78+
}
79+
80+
const opts = toValue(options)
81+
82+
// This only runs for initial load or query changes
83+
// Reset cache for new query
84+
cache.value = null
85+
86+
const params = new URLSearchParams()
87+
params.set('text', q)
88+
// Use requested size for initial fetch
89+
params.set('size', String(opts.size ?? 25))
90+
91+
if (q.length === 1) {
92+
const encodedName = encodePackageName(q)
93+
const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([
94+
cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal }),
95+
cachedFetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`, {
96+
signal,
97+
}),
98+
])
99+
100+
if (!pkg) {
101+
return emptySearchResponse
102+
}
103+
104+
const result = packumentToSearchResult(pkg, downloads?.downloads)
105+
106+
// If query changed/outdated, return empty search response
107+
if (q !== toValue(query)) {
108+
return emptySearchResponse
109+
}
110+
111+
cache.value = {
112+
query: q,
113+
objects: [result],
114+
total: 1,
115+
}
116+
117+
return {
118+
objects: [result],
119+
total: 1,
120+
isStale,
121+
time: new Date().toISOString(),
122+
}
123+
}
124+
125+
const { data: response, isStale } = await cachedFetch<NpmSearchResponse>(
126+
`${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
127+
{ signal },
128+
60,
129+
)
130+
131+
// If query changed/outdated, return empty search response
132+
if (q !== toValue(query)) {
133+
return emptySearchResponse
134+
}
135+
136+
cache.value = {
137+
query: q,
138+
objects: response.objects,
139+
total: response.total,
140+
}
141+
142+
return { ...response, isStale }
143+
},
144+
{ default: () => lastSearch || emptySearchResponse },
145+
)
146+
147+
// Fetch more results incrementally (only used in incremental mode)
148+
async function fetchMore(targetSize: number): Promise<void> {
149+
const q = toValue(query).trim()
150+
if (!q) {
151+
cache.value = null
152+
return
153+
}
154+
155+
// If query changed, reset cache (shouldn't happen, but safety check)
156+
if (cache.value && cache.value.query !== q) {
157+
cache.value = null
158+
await asyncData.refresh()
159+
return
160+
}
161+
162+
const currentCount = cache.value?.objects.length ?? 0
163+
const total = cache.value?.total ?? Infinity
164+
165+
// Already have enough or no more to fetch
166+
if (currentCount >= targetSize || currentCount >= total) {
167+
return
168+
}
169+
170+
isLoadingMore.value = true
171+
172+
try {
173+
// Fetch from where we left off - calculate size needed
174+
const from = currentCount
175+
const size = Math.min(targetSize - currentCount, total - currentCount)
176+
177+
const params = new URLSearchParams()
178+
params.set('text', q)
179+
params.set('size', String(size))
180+
params.set('from', String(from))
181+
182+
const { data: response } = await cachedFetch<NpmSearchResponse>(
183+
`${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
184+
{},
185+
60,
186+
)
187+
188+
// Update cache
189+
if (cache.value && cache.value.query === q) {
190+
const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
191+
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
192+
cache.value = {
193+
query: q,
194+
objects: [...cache.value.objects, ...newObjects],
195+
total: response.total,
196+
}
197+
} else {
198+
cache.value = {
199+
query: q,
200+
objects: response.objects,
201+
total: response.total,
202+
}
203+
}
204+
205+
// If we still need more, fetch again recursively
206+
if (
207+
cache.value.objects.length < targetSize &&
208+
cache.value.objects.length < cache.value.total
209+
) {
210+
await fetchMore(targetSize)
211+
}
212+
} finally {
213+
isLoadingMore.value = false
214+
}
215+
}
216+
217+
// Watch for size increases in incremental mode
218+
watch(
219+
() => toValue(options).size,
220+
async (newSize, oldSize) => {
221+
if (!newSize) return
222+
if (oldSize && newSize > oldSize && toValue(query).trim()) {
223+
await fetchMore(newSize)
224+
}
225+
},
226+
)
227+
228+
// Computed data that uses cache in incremental mode
229+
const data = computed<NpmSearchResponse | null>(() => {
230+
if (cache.value) {
231+
return {
232+
isStale: false,
233+
objects: cache.value.objects,
234+
total: cache.value.total,
235+
time: new Date().toISOString(),
236+
}
237+
}
238+
return asyncData.data.value
239+
})
240+
241+
if (import.meta.client && asyncData.data.value?.isStale) {
242+
onMounted(() => {
243+
asyncData.refresh()
244+
})
245+
}
246+
247+
// Whether there are more results available on the server (incremental mode only)
248+
const hasMore = computed(() => {
249+
if (!cache.value) return true
250+
return cache.value.objects.length < cache.value.total
251+
})
252+
253+
return {
254+
...asyncData,
255+
/** Reactive search results (uses cache in incremental mode) */
256+
data,
257+
/** Whether currently loading more results (incremental mode only) */
258+
isLoadingMore,
259+
/** Whether there are more results available (incremental mode only) */
260+
hasMore,
261+
/** Manually fetch more results up to target size (incremental mode only) */
262+
fetchMore,
263+
}
264+
}

0 commit comments

Comments
 (0)