diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 948decc7bf..1c4be296cb 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -1,5 +1,5 @@ import { normalizeLicense } from '#shared/utils/npm' -import { hasBuiltInTypes } from '~~/shared/utils/package-analysis' +import { analyzePackage, hasBuiltInTypes } from '~~/shared/utils/package-analysis' const DEFAULT_LIMIT = 25 @@ -82,9 +82,44 @@ export default defineCachedEventHandler( } }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) + const visibleVersions = allVersions.slice(offset, offset + limit) + + // 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) return { - versions: allVersions.slice(offset, offset + limit), + versions: visibleVersions, total: allVersions.length, } satisfies TimelineResponse } catch (error: unknown) { 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'