|
| 1 | +import { join } from 'path'; |
| 2 | +import fs from 'fs'; |
| 3 | +import fsp from 'fs/promises'; |
| 4 | +import { tmpdir } from 'os'; |
| 5 | +import https from 'https'; |
| 6 | +import http from 'http'; |
| 7 | +import { env } from 'process'; |
| 8 | +import picocolors from 'picocolors'; |
| 9 | +import type { PackageJson } from 'type-fest'; |
| 10 | + |
| 11 | +const UPDATE_CHECK_INTERVAL = 3600000; |
| 12 | +const UPDATE_CHECK_DIST_TAG = 'latest'; |
| 13 | + |
| 14 | +const compareVersions = (a: string, b: string) => a.localeCompare(b, 'en-US', { numeric: true }); |
| 15 | + |
| 16 | +const getFile = async (name: string, scope: string | undefined, distTag: string) => { |
| 17 | + const subDir = join(tmpdir(), 'update-check'); |
| 18 | + |
| 19 | + if (!fs.existsSync(subDir)) { |
| 20 | + await fsp.mkdir(subDir, { recursive: true }); |
| 21 | + } |
| 22 | + return join(subDir, `${scope ? `${scope}-` : ''}${name}-${distTag}.json`); |
| 23 | +}; |
| 24 | + |
| 25 | +const evaluateCache = async (file: string, time: number, interval: number) => { |
| 26 | + if (fs.existsSync(file)) { |
| 27 | + const content = await fsp.readFile(file, 'utf8'); |
| 28 | + const { lastUpdate, latest } = JSON.parse(content); |
| 29 | + const nextCheck = lastUpdate + interval; |
| 30 | + |
| 31 | + // As long as the time of the next check is in |
| 32 | + // the future, we don't need to run it yet |
| 33 | + if (nextCheck > time) { |
| 34 | + return { |
| 35 | + shouldCheck: false, |
| 36 | + latest |
| 37 | + }; |
| 38 | + } |
| 39 | + } |
| 40 | + |
| 41 | + return { |
| 42 | + shouldCheck: true, |
| 43 | + latest: null |
| 44 | + }; |
| 45 | +}; |
| 46 | + |
| 47 | +const updateCache = (file: string, latest: string | null, lastUpdate: number) => { |
| 48 | + const content = JSON.stringify({ |
| 49 | + latest, |
| 50 | + lastUpdate |
| 51 | + }); |
| 52 | + |
| 53 | + return fsp.writeFile(file, content, 'utf8'); |
| 54 | +}; |
| 55 | + |
| 56 | +const loadPackage = (url: URL) => new Promise<PackageJson>((resolve, reject) => { |
| 57 | + const options: https.RequestOptions = { |
| 58 | + host: url.hostname, |
| 59 | + path: url.pathname, |
| 60 | + port: url.port, |
| 61 | + headers: { |
| 62 | + accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*' |
| 63 | + }, |
| 64 | + timeout: 2000 |
| 65 | + }; |
| 66 | + |
| 67 | + const { get } = url.protocol === 'https:' ? https : http; |
| 68 | + get(options, response => { |
| 69 | + const { statusCode } = response; |
| 70 | + |
| 71 | + if (statusCode !== 200) { |
| 72 | + const error = new Error(`Request failed with code ${statusCode}`); |
| 73 | + Object.defineProperty(error, 'code', { value: statusCode }); |
| 74 | + |
| 75 | + reject(error); |
| 76 | + |
| 77 | + // Consume response data to free up RAM |
| 78 | + response.resume(); |
| 79 | + return; |
| 80 | + } |
| 81 | + |
| 82 | + let rawData = ''; |
| 83 | + response.setEncoding('utf8'); |
| 84 | + |
| 85 | + response.on('data', chunk => { |
| 86 | + rawData += chunk; |
| 87 | + }); |
| 88 | + |
| 89 | + response.on('end', () => { |
| 90 | + try { |
| 91 | + const parsedData = JSON.parse(rawData); |
| 92 | + resolve(parsedData); |
| 93 | + } catch (e) { |
| 94 | + reject(e); |
| 95 | + } |
| 96 | + }); |
| 97 | + }).on('error', reject).on('timeout', reject); |
| 98 | +}); |
| 99 | + |
| 100 | +const getMostRecent = async (distTag: string): Promise<string | null> => { |
| 101 | + const url = new URL(`https://registry.npmjs.org/nolyfill/${distTag}`); |
| 102 | + |
| 103 | + try { |
| 104 | + const spec = await loadPackage(url); |
| 105 | + return spec.version ?? null; |
| 106 | + } catch { |
| 107 | + return null; |
| 108 | + } |
| 109 | +}; |
| 110 | + |
| 111 | +export const checkForUpdates = async (name: string, currentVersion: string): Promise<void> => { |
| 112 | + // Do not check for updates if the `NO_UPDATE_CHECK` variable is set. |
| 113 | + if (env.NO_UPDATE_CHECK) return; |
| 114 | + |
| 115 | + const time = Date.now(); |
| 116 | + |
| 117 | + const isScoped = name.startsWith('/'); |
| 118 | + const parts = name.split('/'); |
| 119 | + const file = await getFile( |
| 120 | + isScoped ? parts[1] : name, |
| 121 | + isScoped ? parts[0] : undefined, |
| 122 | + UPDATE_CHECK_DIST_TAG |
| 123 | + ); |
| 124 | + |
| 125 | + let latest: string | null = null; |
| 126 | + let shouldCheck = true; |
| 127 | + |
| 128 | + ({ shouldCheck, latest } = await evaluateCache(file, time, UPDATE_CHECK_INTERVAL)); |
| 129 | + |
| 130 | + if (shouldCheck) { |
| 131 | + latest = (await getMostRecent(UPDATE_CHECK_DIST_TAG)) ?? null; |
| 132 | + // If we pulled an update, we need to update the cache |
| 133 | + await updateCache(file, latest, time); |
| 134 | + } |
| 135 | + |
| 136 | + if (latest) { |
| 137 | + const comparision = compareVersions(currentVersion, latest); |
| 138 | + |
| 139 | + if (comparision === -1) { |
| 140 | + console.log( |
| 141 | + picocolors.bgRed(picocolors.white(' UPDATE ')), |
| 142 | + `The latest version of "nolyfill" is ${latest}` |
| 143 | + ); |
| 144 | + } |
| 145 | + } |
| 146 | +}; |
0 commit comments