diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 948decc7bf..ff99519b95 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -1,3 +1,4 @@ +import { enrichTimelineVersionTypes } from '#server/utils/package-timeline' import { normalizeLicense } from '#shared/utils/npm' import { hasBuiltInTypes } from '~~/shared/utils/package-analysis' @@ -82,9 +83,11 @@ export default defineCachedEventHandler( } }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) + const visibleVersions = allVersions.slice(offset, offset + limit) + await enrichTimelineVersionTypes(packageName, allVersions, visibleVersions) return { - versions: allVersions.slice(offset, offset + limit), + versions: visibleVersions, total: allVersions.length, } satisfies TimelineResponse } catch (error: unknown) { diff --git a/server/utils/package-timeline.ts b/server/utils/package-timeline.ts new file mode 100644 index 0000000000..c37cfb70fb --- /dev/null +++ b/server/utils/package-timeline.ts @@ -0,0 +1,44 @@ +import { analyzePackage } from '#shared/utils/package-analysis' + +interface TimelineVersionWithTypes { + version: string + hasTypes?: boolean +} + +export async function enrichTimelineVersionTypes( + packageName: string, + allVersions: TimelineVersionWithTypes[], + visibleVersions: TimelineVersionWithTypes[], +): Promise { + // File-aware detection is limited to potential removal events: metadata-untyped + // versions with an older typed release. Checking every untyped version would + // require additional registry and file-tree requests. + const possibleTypeRemovals = visibleVersions + .filter(version => { + if (version.hasTypes) return false + + const versionIndex = allVersions.indexOf(version) + return allVersions.slice(versionIndex + 1).some(previousVersion => previousVersion.hasTypes) + }) + .map(async version => { + try { + const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles( + packageName, + version.version, + ) + + const analysis = analyzePackage(pkg, { + typesPackage, + files, + }) + + if (analysis.types.kind === 'included') { + version.hasTypes = true + } + } catch { + // Preserve the metadata-only result when the file list is unavailable. + } + }) + + await Promise.all(possibleTypeRemovals) +} diff --git a/test/unit/server/api/registry/timeline/pkg.get.spec.ts b/test/unit/server/api/registry/timeline/pkg.get.spec.ts index 44fea5a101..0d7cbf3acc 100644 --- a/test/unit/server/api/registry/timeline/pkg.get.spec.ts +++ b/test/unit/server/api/registry/timeline/pkg.get.spec.ts @@ -3,7 +3,9 @@ import { createError, type H3Event } from 'h3' import type { Packument, PackumentVersion } from '#shared/types/npm-registry' const fetchNpmPackageMock = vi.fn() +const fetchPackageWithTypesAndFilesMock = vi.fn() vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) +vi.stubGlobal('fetchPackageWithTypesAndFiles', fetchPackageWithTypesAndFilesMock) vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300) @@ -228,6 +230,83 @@ describe('timeline API', () => { expect(result.versions[0]!.hasTypes).toBe(true) }) + it('sets hasTypes for consecutive versions with declaration files', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { + types: './dist/index.d.ts', + }, + '2.0.0': { + main: './dist/index.mjs', + }, + '3.0.0': { + main: './dist/index.mjs', + }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2025-01-01T00:00:00Z', + '3.0.0': '2026-01-01T00:00:00Z', + }, + }), + ) + + fetchPackageWithTypesAndFilesMock.mockImplementation( + async (packageName: string, version: string) => ({ + pkg: { + name: packageName, + version, + main: './dist/index.mjs', + }, + files: new Set(['dist/index.mjs', 'dist/index.d.mts']), + }), + ) + + const result = await handler(fakeEvent) + + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '2.0.0') + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '3.0.0') + expect(result.versions).toHaveLength(3) + expect(result.versions[0]?.hasTypes).toBe(true) + expect(result.versions[1]?.hasTypes).toBe(true) + expect(result.versions[2]?.hasTypes).toBe(true) + }) + + it('keeps hasTypes unset when declarations are removed', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { types: './dist/index.d.ts' }, + '2.0.0': { main: './dist/index.mjs' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2025-01-01T00:00:00Z', + }, + }), + ) + + fetchPackageWithTypesAndFilesMock.mockResolvedValue({ + pkg: { + name: 'my-pkg', + version: '2.0.0', + main: './dist/index.mjs', + }, + files: new Set(['dist/index.mjs']), + }) + + const result = await handler(fakeEvent) + + expect(result.versions).toHaveLength(2) + expect(result.versions[0]?.hasTypes).toBeUndefined() + expect(result.versions[1]?.hasTypes).toBe(true) + }) + it('sets hasTrustedPublisher when trustedPublisher is true', async () => { routerParam = 'my-pkg'