diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 948decc7bf..a115912d80 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -1,8 +1,12 @@ 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 +// Upper bound on per-request file tree lookups when double-checking +// versions that would otherwise produce a "types removed" event. +const MAX_FILE_TREE_CHECKS = 5 + export interface TimelineVersion { version: string time: string @@ -83,6 +87,34 @@ export default defineCachedEventHandler( }) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) + // A missing `types` field doesn't always mean a version stopped + // shipping types: some packages only carry declaration files next + // to their entry points (#2791). Before a "types removed" event + // can show up, re-check those versions against their file tree + // with the same detection the package page uses. Oldest first so + // a corrected version is taken into account by the next pair. + let fileTreeChecks = 0 + for (let i = allVersions.length - 2; i >= 0; i--) { + const current = allVersions[i]! + const previous = allVersions[i + 1]! + if (current.hasTypes || !previous.hasTypes || fileTreeChecks >= MAX_FILE_TREE_CHECKS) { + continue + } + fileTreeChecks++ + try { + const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles( + packageName, + current.version, + ) + const { types } = analyzePackage(pkg, { typesPackage, files }) + if (types.kind === 'included') { + current.hasTypes = true + } + } catch { + // file tree unavailable, keep the packument-based value + } + } + return { versions: allVersions.slice(offset, offset + limit), total: allVersions.length, 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..01733571c9 100644 --- a/test/unit/server/api/registry/timeline/pkg.get.spec.ts +++ b/test/unit/server/api/registry/timeline/pkg.get.spec.ts @@ -4,6 +4,9 @@ import type { Packument, PackumentVersion } from '#shared/types/npm-registry' const fetchNpmPackageMock = vi.fn() vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) + +const fetchPackageWithTypesAndFilesMock = vi.fn() +vi.stubGlobal('fetchPackageWithTypesAndFiles', fetchPackageWithTypesAndFilesMock) vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300) @@ -228,6 +231,76 @@ describe('timeline API', () => { expect(result.versions[0]!.hasTypes).toBe(true) }) + it('sets hasTypes for versions that ship declaration files without a types field', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { types: './index.d.ts' }, + '2.0.0': { main: './dist/index.cjs', module: './dist/index.mjs' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-02-01T00:00:00Z', + }, + }), + ) + fetchPackageWithTypesAndFilesMock.mockResolvedValue({ + pkg: { main: './dist/index.cjs', module: './dist/index.mjs' }, + files: new Set(['dist/index.d.mts', 'dist/index.d.cts']), + }) + + const result = await handler(fakeEvent) + expect(fetchPackageWithTypesAndFilesMock).toHaveBeenCalledWith('my-pkg', '2.0.0') + expect(result.versions[0]!.hasTypes).toBe(true) + }) + + it('leaves hasTypes unset when the file tree has no declaration files', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { types: './index.d.ts' }, + '2.0.0': { main: './dist/index.cjs', module: './dist/index.mjs' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-02-01T00:00:00Z', + }, + }), + ) + fetchPackageWithTypesAndFilesMock.mockResolvedValue({ + pkg: { main: './dist/index.cjs', module: './dist/index.mjs' }, + files: new Set(['dist/index.mjs']), + }) + + const result = await handler(fakeEvent) + expect(result.versions[0]!.hasTypes).toBeUndefined() + }) + + it('keeps the packument value when the file tree lookup fails', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { types: './index.d.ts' }, + '2.0.0': { main: './dist/index.cjs' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-02-01T00:00:00Z', + }, + }), + ) + fetchPackageWithTypesAndFilesMock.mockRejectedValue(new Error('offline')) + + const result = await handler(fakeEvent) + expect(result.versions[0]!.hasTypes).toBeUndefined() + }) + it('sets hasTrustedPublisher when trustedPublisher is true', async () => { routerParam = 'my-pkg'