Skip to content
34 changes: 33 additions & 1 deletion server/api/registry/timeline/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -76,13 +80,41 @@
license: normalizeLicense(version.license),
type: typeof version.type === 'string' ? version.type : undefined,
hasTypes: hasBuiltInTypes(version) || undefined,
hasTrustedPublisher: version._npmUser?.trustedPublisher ? true : undefined,

Check warning on line 83 in server/api/registry/timeline/[...pkg].get.ts

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_npmUser`'.
hasProvenance: version.dist?.attestations ? true : undefined,
tags: tagsByVersion.get(v) ?? [],
}
})
.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,
Expand Down
73 changes: 73 additions & 0 deletions test/unit/server/api/registry/timeline/pkg.get.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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'

Expand Down
Loading