UNPKG

5.89 kBJavaScriptView Raw
1import { existsSync } from "node:fs";
2import { readFile } from "node:fs/promises";
3import os from "node:os";
4import { pathToFileURL } from "node:url";
5import { environment } from "./env.js";
6import { AppError } from "./error.js";
7import { isNil } from "./lodash.js";
8import { pathJoin } from "./node.js";
9
10/**
11 * @typedef {object} ConfigLoaderOptions
12 * @property {string} name
13 * @property {"project"|"user"} location
14 */
15
16/**
17 * @typedef {object} ConfigLoaderResult
18 * @property {string} name
19 * @property {"project"|"user"} location
20 * @property {{
21 * directory: string,
22 * filename: string,
23 * }} [resolvedLocation]
24 * @property {object} data
25 */
26
27/**
28 *
29 * @type {Map<string, ConfigLoaderResult>}
30 */
31export const configLoaderCache = new Map();
32
33/**
34 *
35 * @param {ConfigLoaderOptions} options
36 * @returns {Promise<ConfigLoaderResult>}
37 */
38export async function configLoaderGet(options) {
39 if (
40 isNil(options.name) ||
41 typeof options.name !== "string" ||
42 !["project", "user"].includes(options.location)
43 ) {
44 throw AppError.serverError({
45 message:
46 "Invalid config options object. 'name' and 'location' are both required and should be a string.",
47 });
48 }
49
50 const cacheKey = `${options.name}-${options.location}`;
51
52 if (configLoaderCache.has(cacheKey)) {
53 // @ts-ignore
54 return configLoaderCache.get(cacheKey);
55 }
56
57 const resolvedLocation = configLoaderResolveDirectory(options);
58
59 if (isNil(resolvedLocation)) {
60 configLoaderCache.set(cacheKey, {
61 name: options.name,
62 location: options.location,
63 data: {},
64 });
65
66 // @ts-ignore
67 return configLoaderCache.get(cacheKey);
68 }
69
70 let data;
71
72 if (resolvedLocation.filename.endsWith(".json")) {
73 data = JSON.parse(
74 await readFile(
75 pathJoin(resolvedLocation.directory, resolvedLocation.filename),
76 "utf-8",
77 ),
78 );
79 } else {
80 const imported = await import(
81 // @ts-ignore
82 pathToFileURL(
83 pathJoin(resolvedLocation.directory, resolvedLocation.filename),
84 )
85 );
86 data = imported.config() ?? {};
87 }
88
89 configLoaderCache.set(cacheKey, {
90 name: options.name,
91 location: options.location,
92 resolvedLocation,
93 data,
94 });
95
96 // @ts-ignore
97 return configLoaderCache.get(cacheKey);
98}
99
100/**
101 * Clear the config cache.
102 * Is able to do partially removes by either specifying a name or location but not both.
103 *
104 * @param {{
105 * name?: string,
106 * location?: "project"|"user"
107 * }} [options]
108 * @returns {void}
109 */
110export function configLoaderDeleteCache(options) {
111 options = options ?? {};
112
113 if (options.name && options.location) {
114 throw AppError.serverError({
115 message:
116 "'name' and 'location' can't both be specified. So remove one of them or both.",
117 });
118 }
119
120 if (!isNil(options.name)) {
121 for (const key of configLoaderCache.keys()) {
122 if (key.startsWith(options.name)) {
123 configLoaderCache.delete(key);
124 }
125 }
126 } else if (!isNil(options.location)) {
127 for (const key of configLoaderCache.keys()) {
128 if (key.endsWith(options.location)) {
129 configLoaderCache.delete(key);
130 }
131 }
132 } else {
133 configLoaderCache.clear();
134 }
135}
136
137/**
138 *
139 * @param {ConfigLoaderOptions} options
140 * @returns {{
141 * directory: string,
142 * filename: string,
143 * }|undefined}
144 */
145function configLoaderResolveDirectory({ name, location }) {
146 if (location === "project") {
147 if (existsSync(`./${name}.json`)) {
148 return {
149 directory: process.cwd(),
150 filename: `${name}.json`,
151 };
152 }
153
154 if (existsSync(`./${name}.js`)) {
155 return {
156 directory: process.cwd(),
157 filename: `${name}.js`,
158 };
159 }
160
161 if (existsSync(`./${name}.mjs`)) {
162 return {
163 directory: process.cwd(),
164 filename: `${name}.mjs`,
165 };
166 }
167
168 if (existsSync(pathJoin(`./config`, `./${name}.json`))) {
169 return {
170 directory: pathJoin(process.cwd(), "./config"),
171 filename: `${name}.json`,
172 };
173 }
174
175 if (existsSync(pathJoin(`./config`, `./${name}.js`))) {
176 return {
177 directory: pathJoin(process.cwd(), "./config"),
178 filename: `${name}.js`,
179 };
180 }
181
182 if (existsSync(pathJoin(`./config`, `./${name}.mjs`))) {
183 return {
184 directory: pathJoin(process.cwd(), "./config"),
185 filename: `${name}.mjs`,
186 };
187 }
188
189 return undefined;
190 }
191
192 if (location === "user") {
193 const configDir = configLoaderGetUserConfigDir(name);
194
195 if (existsSync(pathJoin(configDir, `./${name}.json`))) {
196 return {
197 directory: configDir,
198 filename: `${name}.json`,
199 };
200 }
201
202 if (existsSync(pathJoin(configDir, `./${name}.js`))) {
203 return {
204 directory: configDir,
205 filename: `${name}.js`,
206 };
207 }
208
209 if (existsSync(pathJoin(configDir, `./${name}.mjs`))) {
210 return {
211 directory: configDir,
212 filename: `${name}.mjs`,
213 };
214 }
215
216 return undefined;
217 }
218}
219
220/**
221 * Determine the config dir based on the program name.
222 * We don't support different config file for the same program. So the config directory
223 * is the same as the config name.
224 *
225 * @param {string} name
226 * @returns {string}
227 */
228function configLoaderGetUserConfigDir(name) {
229 // Inspired by:
230 // https://github.com/sindresorhus/env-paths/tree/f1729272888f45f6584e74dc4d0af3aecba9e7e8
231 // License:
232 // https://github.com/sindresorhus/env-paths/blob/f1729272888f45f6584e74dc4d0af3aecba9e7e8/license
233
234 const homedir = os.homedir();
235
236 if (process.platform === "darwin") {
237 return pathJoin(homedir, "Library", "Preferences", name);
238 }
239
240 if (process.platform === "win32") {
241 return pathJoin(
242 environment.APPDATA ??
243 pathJoin(homedir, "AppData", "Roaming", name, "Config"),
244 );
245 }
246
247 // Linux
248 return pathJoin(
249 environment.XDG_CONFIG_HOME ?? pathJoin(homedir, ".config"),
250 name,
251 );
252}