Skip to content
37 changes: 36 additions & 1 deletion server/api/registry/timeline/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { normalizeLicense } from '#shared/utils/npm'
import { hasBuiltInTypes } from '~~/shared/utils/package-analysis'
import { detectTypesStatus, hasBuiltInTypes } from '~~/shared/utils/package-analysis'
import { flattenFileTree } from '~~/server/utils/import-resolver'

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 @@ -83,6 +88,36 @@ 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 fileTree = await getPackageFileTree(
packageName,
current.version,
AbortSignal.timeout(5000),
)
const files = flattenFileTree(fileTree.tree)
const status = detectTypesStatus(packument.versions[current.version]!, undefined, files)
if (status.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
78 changes: 78 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 getPackageFileTreeMock = vi.fn()
vi.stubGlobal('getPackageFileTree', getPackageFileTreeMock)
vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn)
vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300)

Expand Down Expand Up @@ -228,6 +231,81 @@ 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',
},
}),
)
getPackageFileTreeMock.mockResolvedValue({
package: 'my-pkg',
version: '2.0.0',
tree: [
{ name: 'index.d.mts', path: 'dist/index.d.mts', type: 'file' },
{ name: 'index.d.cts', path: 'dist/index.d.cts', type: 'file' },
],
})

const result = await handler(fakeEvent)
expect(getPackageFileTreeMock).toHaveBeenCalledWith('my-pkg', '2.0.0', expect.any(AbortSignal))
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',
},
}),
)
getPackageFileTreeMock.mockResolvedValue({
package: 'my-pkg',
version: '2.0.0',
tree: [{ name: 'index.mjs', path: 'dist/index.mjs', type: 'file' }],
})

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',
},
}),
)
getPackageFileTreeMock.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