freshness-dependency 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -0
- package/src/index.ts +287 -0
- package/tsconfig.json +4 -0
- package/wrangler.toml +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "freshness-dependency",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Dependency Freshness Check - safe npm upgrade suggestions at the edge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"deploy": "wrangler deploy",
|
|
9
|
+
"lint": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"semver": "^7.6.2"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@cloudflare/workers-types": "^4.20241205.0",
|
|
16
|
+
"typescript": "^5.7.2",
|
|
17
|
+
"wrangler": "^3.99.0"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
|
|
3
|
+
export interface Env {
|
|
4
|
+
NPM_CACHE?: KVNamespace;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface PackageMeta {
|
|
8
|
+
name: string;
|
|
9
|
+
latest: string | null;
|
|
10
|
+
versions: string[];
|
|
11
|
+
deprecated: Record<string, string>;
|
|
12
|
+
latestPublished: string | null;
|
|
13
|
+
fetchedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PackageResult {
|
|
17
|
+
name: string;
|
|
18
|
+
current: string;
|
|
19
|
+
latest: string | null;
|
|
20
|
+
suggest: string;
|
|
21
|
+
compatible: string | null;
|
|
22
|
+
type: 'prod' | 'dev';
|
|
23
|
+
outdated: boolean;
|
|
24
|
+
breaking: boolean;
|
|
25
|
+
stale: boolean;
|
|
26
|
+
stalenessDays: number | null;
|
|
27
|
+
deprecated: boolean;
|
|
28
|
+
deprecatedLatest: boolean;
|
|
29
|
+
lastPublished: string | null;
|
|
30
|
+
notes?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CORS = {
|
|
34
|
+
'Access-Control-Allow-Origin': '*',
|
|
35
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
36
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const json = (data: unknown, status = 200) =>
|
|
40
|
+
new Response(JSON.stringify(data, null, 2), {
|
|
41
|
+
status,
|
|
42
|
+
headers: { 'Content-Type': 'application/json', ...CORS },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const bad = (message: string, status = 400) => json({ error: message }, status);
|
|
46
|
+
|
|
47
|
+
// Consider a package "stale" when it has not shipped a release in ~18 months.
|
|
48
|
+
const STALE_THRESHOLD_DAYS = 540;
|
|
49
|
+
|
|
50
|
+
async function fetchPackageMeta(name: string, env: Env, includePrerelease: boolean): Promise<PackageMeta> {
|
|
51
|
+
const cacheKey = `pkg:${name}:${includePrerelease ? 'pr' : 'stable'}`;
|
|
52
|
+
const cached = env.NPM_CACHE && await env.NPM_CACHE.get<PackageMeta>(cacheKey, { type: 'json' });
|
|
53
|
+
if (cached) return { deprecated: {}, latestPublished: null, ...cached };
|
|
54
|
+
|
|
55
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
56
|
+
headers: { 'User-Agent': 'workerscando-dependency-freshness/1.0' },
|
|
57
|
+
cf: { cacheEverything: true, cacheTtl: 300 },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`npm registry returned ${res.status}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const data = await res.json<any>();
|
|
65
|
+
const versionsAll = Object.keys(data.versions || {});
|
|
66
|
+
const versions = versionsAll
|
|
67
|
+
.filter(v => includePrerelease || !semver.prerelease(v))
|
|
68
|
+
.sort(semver.rcompare);
|
|
69
|
+
|
|
70
|
+
const latestTag = data['dist-tags']?.latest as string | undefined;
|
|
71
|
+
const latest = latestTag && (!semver.prerelease(latestTag) || includePrerelease)
|
|
72
|
+
? latestTag
|
|
73
|
+
: versions[0] || null;
|
|
74
|
+
|
|
75
|
+
const deprecated: Record<string, string> = {};
|
|
76
|
+
for (const [version, info] of Object.entries<any>(data.versions || {})) {
|
|
77
|
+
if (info?.deprecated) deprecated[version] = String(info.deprecated);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const timeMap = data.time || {};
|
|
81
|
+
const latestPublished = latest && typeof timeMap[latest] === 'string'
|
|
82
|
+
? timeMap[latest]
|
|
83
|
+
: (versions[0] && typeof timeMap[versions[0]] === 'string' ? timeMap[versions[0]] : null);
|
|
84
|
+
|
|
85
|
+
const meta: PackageMeta = {
|
|
86
|
+
name,
|
|
87
|
+
latest: latest || null,
|
|
88
|
+
versions,
|
|
89
|
+
deprecated,
|
|
90
|
+
latestPublished,
|
|
91
|
+
fetchedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (env.NPM_CACHE) {
|
|
95
|
+
// Short TTL so registry changes propagate quickly
|
|
96
|
+
await env.NPM_CACHE.put(cacheKey, JSON.stringify(meta), { expirationTtl: 1800 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return meta;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function safeSuggestion(range: string, min: semver.SemVer | null, meta: PackageMeta, includePrerelease: boolean): { suggest: string; compatible: string | null; breaking: boolean; outdated: boolean; notes?: string } {
|
|
103
|
+
const { latest, versions } = meta;
|
|
104
|
+
if (!latest || !min) return { suggest: range, compatible: null, breaking: false, outdated: false };
|
|
105
|
+
|
|
106
|
+
const isExact = Boolean(semver.valid(range));
|
|
107
|
+
const satisfiesLatest = semver.satisfies(latest, range, { includePrerelease });
|
|
108
|
+
const compatible = semver.maxSatisfying(versions, range, { includePrerelease });
|
|
109
|
+
const sameMajor = versions.find(v => semver.major(v) === semver.major(min) && (includePrerelease || !semver.prerelease(v)));
|
|
110
|
+
const breaking = semver.major(latest) !== semver.major(min);
|
|
111
|
+
|
|
112
|
+
let suggest = range;
|
|
113
|
+
|
|
114
|
+
if (!satisfiesLatest) {
|
|
115
|
+
if (range.startsWith('^') && compatible) {
|
|
116
|
+
suggest = `^${compatible}`;
|
|
117
|
+
} else if (range.startsWith('~') && compatible) {
|
|
118
|
+
suggest = `~${compatible}`;
|
|
119
|
+
} else if (range === '*' || range.toLowerCase() === 'latest') {
|
|
120
|
+
suggest = latest;
|
|
121
|
+
} else if (isExact && sameMajor && sameMajor !== range) {
|
|
122
|
+
// Pinned version: offer latest within same major as safe bump
|
|
123
|
+
suggest = sameMajor;
|
|
124
|
+
} else if (compatible) {
|
|
125
|
+
suggest = compatible;
|
|
126
|
+
} else {
|
|
127
|
+
suggest = latest;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const outdated = !satisfiesLatest || (isExact && suggest !== range);
|
|
132
|
+
const notes = outdated && breaking ? 'Latest release is a new major version' : undefined;
|
|
133
|
+
|
|
134
|
+
return { suggest, compatible: compatible || null, breaking: breaking && outdated, outdated, notes };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildInstallScript(pkgs: PackageResult[]): string | null {
|
|
138
|
+
const upgrades = pkgs.filter(p => p.outdated).map(p => `${p.name}@${p.suggest}`);
|
|
139
|
+
if (!upgrades.length) return null;
|
|
140
|
+
return `npm install ${upgrades.join(' ')}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function parseManifest(body: any): Promise<{ dependencies: Record<string, string>; devDependencies: Record<string, string> }> {
|
|
144
|
+
if (!body) throw new Error('Missing request body');
|
|
145
|
+
|
|
146
|
+
if (body.packageJson && typeof body.packageJson === 'object') {
|
|
147
|
+
return {
|
|
148
|
+
dependencies: body.packageJson.dependencies || {},
|
|
149
|
+
devDependencies: body.packageJson.devDependencies || {},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (body.url && typeof body.url === 'string') {
|
|
154
|
+
const res = await fetch(body.url);
|
|
155
|
+
if (!res.ok) throw new Error(`Could not fetch package.json from URL (${res.status})`);
|
|
156
|
+
const pkg = await res.json();
|
|
157
|
+
return {
|
|
158
|
+
dependencies: pkg.dependencies || {},
|
|
159
|
+
devDependencies: pkg.devDependencies || {},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error('Provide packageJson object or url');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default {
|
|
167
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
168
|
+
if (req.method === 'OPTIONS') return new Response(null, { headers: CORS });
|
|
169
|
+
|
|
170
|
+
const url = new URL(req.url);
|
|
171
|
+
if (url.pathname !== '/analyze' && url.pathname !== '/api/freshness') {
|
|
172
|
+
return json({
|
|
173
|
+
api: 'Dependency Freshness Check',
|
|
174
|
+
endpoints: ['POST /analyze', 'POST /api/freshness'],
|
|
175
|
+
sample: {
|
|
176
|
+
dependencies: { react: '^18.2.0' },
|
|
177
|
+
devDependencies: { eslint: '^8.55.0' },
|
|
178
|
+
includeDev: true,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (req.method !== 'POST') {
|
|
184
|
+
return bad('Use POST with a package.json payload');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let body: any;
|
|
188
|
+
try {
|
|
189
|
+
body = await req.json();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return bad('Invalid JSON body');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const includeDev = Boolean(body.includeDev);
|
|
195
|
+
const allowPrerelease = Boolean(body.allowPrerelease);
|
|
196
|
+
|
|
197
|
+
let manifest;
|
|
198
|
+
try {
|
|
199
|
+
manifest = await parseManifest(body);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return bad(e instanceof Error ? e.message : 'Unable to read package.json');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const entries: Array<{ name: string; range: string; type: 'prod' | 'dev' }> = [];
|
|
205
|
+
for (const [name, range] of Object.entries(manifest.dependencies || {})) {
|
|
206
|
+
if (typeof range === 'string') entries.push({ name, range, type: 'prod' });
|
|
207
|
+
}
|
|
208
|
+
if (includeDev) {
|
|
209
|
+
for (const [name, range] of Object.entries(manifest.devDependencies || {})) {
|
|
210
|
+
if (typeof range === 'string') entries.push({ name, range, type: 'dev' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!entries.length) return bad('No dependencies found');
|
|
215
|
+
if (entries.length > 200) return bad('Too many dependencies (limit 200)');
|
|
216
|
+
|
|
217
|
+
const results: PackageResult[] = [];
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
try {
|
|
220
|
+
const meta = await fetchPackageMeta(entry.name, env, allowPrerelease);
|
|
221
|
+
const min = semver.minVersion(entry.range);
|
|
222
|
+
const { suggest, compatible, breaking, outdated, notes } = safeSuggestion(entry.range, min, meta, allowPrerelease);
|
|
223
|
+
|
|
224
|
+
const resolvedVersion = compatible || (min ? min.version : null);
|
|
225
|
+
const deprecatedCurrentMsg = resolvedVersion ? meta.deprecated[resolvedVersion] : undefined;
|
|
226
|
+
const deprecatedLatestMsg = meta.latest ? meta.deprecated[meta.latest] : undefined;
|
|
227
|
+
|
|
228
|
+
const lastPublished = meta.latestPublished;
|
|
229
|
+
const stalenessDays = lastPublished ? Math.round((Date.now() - Date.parse(lastPublished)) / 86_400_000) : null;
|
|
230
|
+
const stale = stalenessDays !== null && stalenessDays > STALE_THRESHOLD_DAYS;
|
|
231
|
+
|
|
232
|
+
const noteParts = [];
|
|
233
|
+
if (notes) noteParts.push(notes);
|
|
234
|
+
if (deprecatedCurrentMsg) noteParts.push('Current range resolves to a deprecated release');
|
|
235
|
+
if (deprecatedLatestMsg) noteParts.push('Latest release is marked deprecated');
|
|
236
|
+
if (stale) noteParts.push(`No new release in ${stalenessDays} days`);
|
|
237
|
+
const mergedNotes = noteParts.length ? noteParts.join('; ') : undefined;
|
|
238
|
+
|
|
239
|
+
results.push({
|
|
240
|
+
name: entry.name,
|
|
241
|
+
current: entry.range,
|
|
242
|
+
latest: meta.latest,
|
|
243
|
+
suggest,
|
|
244
|
+
compatible,
|
|
245
|
+
type: entry.type,
|
|
246
|
+
outdated,
|
|
247
|
+
breaking,
|
|
248
|
+
stale,
|
|
249
|
+
stalenessDays,
|
|
250
|
+
deprecated: Boolean(deprecatedCurrentMsg),
|
|
251
|
+
deprecatedLatest: Boolean(deprecatedLatestMsg),
|
|
252
|
+
lastPublished,
|
|
253
|
+
notes: mergedNotes,
|
|
254
|
+
});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
results.push({
|
|
257
|
+
name: entry.name,
|
|
258
|
+
current: entry.range,
|
|
259
|
+
latest: null,
|
|
260
|
+
suggest: entry.range,
|
|
261
|
+
compatible: null,
|
|
262
|
+
type: entry.type,
|
|
263
|
+
outdated: false,
|
|
264
|
+
breaking: false,
|
|
265
|
+
stale: false,
|
|
266
|
+
stalenessDays: null,
|
|
267
|
+
deprecated: false,
|
|
268
|
+
deprecatedLatest: false,
|
|
269
|
+
lastPublished: null,
|
|
270
|
+
notes: err instanceof Error ? err.message : 'Failed to check',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const summary = {
|
|
276
|
+
total: results.length,
|
|
277
|
+
outdated: results.filter(r => r.outdated).length,
|
|
278
|
+
breaking: results.filter(r => r.breaking).length,
|
|
279
|
+
stale: results.filter(r => r.stale).length,
|
|
280
|
+
deprecated: results.filter(r => r.deprecated || r.deprecatedLatest).length,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const installScript = buildInstallScript(results);
|
|
284
|
+
|
|
285
|
+
return json({ summary, packages: results, installScript });
|
|
286
|
+
},
|
|
287
|
+
};
|
package/tsconfig.json
ADDED
package/wrangler.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
name = "dependency-freshness"
|
|
2
|
+
main = "src/index.ts"
|
|
3
|
+
compatibility_date = "2024-01-01"
|
|
4
|
+
|
|
5
|
+
[[kv_namespaces]]
|
|
6
|
+
binding = "NPM_CACHE"
|
|
7
|
+
# Replace with your KV namespace id after creating it
|
|
8
|
+
id = "1db97b5adaae4efe94e0704a9dd7d4d1"
|
|
9
|
+
account_id = "c61e632ec76dace5a759048edc6d3641"
|