1 | import zlib from 'zlib';
|
2 | import { promises as fs } from 'fs';
|
3 | import { promisify } from 'util';
|
4 | import path from 'path';
|
5 | import os from 'os';
|
6 | import fetch from 'node-fetch';
|
7 | import semver from 'semver';
|
8 | import _debug from 'debug';
|
9 | const debug = _debug('mongodb-download-url:version-list');
|
10 | const gunzip = promisify(zlib.gunzip);
|
11 | const gzip = promisify(zlib.gzip);
|
12 |
|
13 | export type ArchiveBaseInfo = {
|
14 | sha1: string;
|
15 | sha256: string;
|
16 | url: string;
|
17 | };
|
18 |
|
19 | export type DownloadInfo = {
|
20 | edition: 'enterprise' | 'targeted' | 'base' | 'source' | 'subscription';
|
21 | target?: string;
|
22 | arch?: string;
|
23 |
|
24 | archive: {
|
25 | debug_symbols: string;
|
26 | } & ArchiveBaseInfo;
|
27 |
|
28 | cryptd?: ArchiveBaseInfo;
|
29 | shell?: ArchiveBaseInfo;
|
30 | packages?: string[];
|
31 | msi?: string;
|
32 | };
|
33 |
|
34 | export type VersionInfo = {
|
35 | changes: string;
|
36 | notes: string;
|
37 | date: string;
|
38 | githash: string;
|
39 |
|
40 | continuous_release: boolean;
|
41 | current: boolean;
|
42 | development_release: boolean;
|
43 | lts_release: boolean;
|
44 | production_release: boolean;
|
45 | release_candidate: boolean;
|
46 | version: string;
|
47 |
|
48 | downloads: DownloadInfo[];
|
49 | };
|
50 |
|
51 | type FullJSON = {
|
52 | versions: VersionInfo[];
|
53 | };
|
54 |
|
55 | export type VersionListOpts = {
|
56 | version?: string;
|
57 | versionListUrl?: string;
|
58 | cachePath?: string;
|
59 | cacheTimeMs?: number;
|
60 | productionOnly?: boolean;
|
61 | };
|
62 |
|
63 | function defaultCachePath(): string {
|
64 | return path.join(os.tmpdir(), '.mongodb-full.json.gz');
|
65 | }
|
66 |
|
67 | let fullJSON: FullJSON | undefined;
|
68 | let fullJSONFetchTime = 0;
|
69 | async function getFullJSON(opts: VersionListOpts): Promise<FullJSON> {
|
70 | const versionListUrl = opts.versionListUrl ?? 'https://downloads.mongodb.org/full.json';
|
71 | const cachePath = opts.cachePath ?? defaultCachePath();
|
72 | const cacheTimeMs = opts.cacheTimeMs ?? 24 * 3600 * 1000;
|
73 | let tryWriteCache = cacheTimeMs > 0;
|
74 | const inMemoryCopyUpToDate = () => fullJSONFetchTime >= new Date().getTime() - cacheTimeMs;
|
75 |
|
76 | try {
|
77 | if ((!fullJSON || !inMemoryCopyUpToDate()) && cacheTimeMs > 0) {
|
78 | debug('trying to load versions from cache', cachePath);
|
79 | const fh = await fs.open(cachePath, 'r');
|
80 | try {
|
81 | const stat = await fh.stat();
|
82 | if (process.getuid && (stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0)) {
|
83 | tryWriteCache = false;
|
84 | debug('cannot use cache because it is not a file or we do not own it');
|
85 | throw new Error();
|
86 | }
|
87 | if (stat.mtime.getTime() < new Date().getTime() - cacheTimeMs) {
|
88 | debug('cache is outdated');
|
89 | throw new Error();
|
90 | }
|
91 | debug('cache up-to-date');
|
92 | tryWriteCache = false;
|
93 | fullJSON = JSON.parse((await gunzip(await fh.readFile())).toString());
|
94 | fullJSONFetchTime = new Date().getTime();
|
95 | } finally {
|
96 | await fh.close();
|
97 | }
|
98 | }
|
99 | } catch {}
|
100 | if (!fullJSON || !inMemoryCopyUpToDate()) {
|
101 | debug('trying to load versions from source', versionListUrl);
|
102 | const response = await fetch(versionListUrl);
|
103 | if (!response.ok) {
|
104 | throw new Error(`Could not get mongodb versions from ${versionListUrl}: ${response.statusText}`);
|
105 | }
|
106 | fullJSON = await response.json();
|
107 | fullJSONFetchTime = new Date().getTime();
|
108 | if (tryWriteCache) {
|
109 | const partialFilePath = cachePath + `.partial.${process.pid}`;
|
110 | await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
111 | try {
|
112 | const compressed = await gzip(JSON.stringify(fullJSON), { level: 9 });
|
113 | await fs.writeFile(partialFilePath, compressed, { mode: 0o644, flag: 'wx' });
|
114 | await fs.rename(partialFilePath, cachePath);
|
115 | debug('wrote cache', cachePath);
|
116 | } catch {
|
117 | try {
|
118 | await fs.unlink(partialFilePath);
|
119 | } catch {}
|
120 | }
|
121 | }
|
122 | }
|
123 | return fullJSON;
|
124 | }
|
125 |
|
126 | export async function getVersion(opts: VersionListOpts): Promise<VersionInfo> {
|
127 | const fullJSON = await getFullJSON(opts);
|
128 | let versions = fullJSON.versions;
|
129 | versions = versions.filter((info: VersionInfo) => info.downloads.length > 0);
|
130 | if (opts.productionOnly) {
|
131 | versions = versions.filter((info: VersionInfo) => info.production_release);
|
132 | }
|
133 | if (opts.version && opts.version !== '*') {
|
134 | versions = versions.filter((info: VersionInfo) => semver.satisfies(info.version, opts.version));
|
135 | }
|
136 | versions = versions.sort((a: VersionInfo, b: VersionInfo) => semver.rcompare(a.version, b.version));
|
137 | return versions[0];
|
138 | }
|
139 |
|
140 | export async function clearCache(cachePath?: string): Promise<void> {
|
141 | debug('clearing cache');
|
142 | fullJSON = undefined;
|
143 | fullJSONFetchTime = 0;
|
144 | if (cachePath !== '') {
|
145 | try {
|
146 | await fs.unlink(cachePath ?? defaultCachePath());
|
147 | } catch (err) {
|
148 | if (err.code === 'ENOENT') return;
|
149 | throw err;
|
150 | }
|
151 | }
|
152 | }
|