UNPKG

11.2 kBJavaScriptView Raw
1const fs = require('fs-extra');
2const path = require('path');
3const { default: PQueue } = require('p-queue');
4const {
5 ApiErrorContext,
6 FileSystemErrorContext,
7 logApiErrorInstance,
8 logFileSystemErrorInstance,
9} = require('./errorHandlers');
10const { logger } = require('./logger');
11const {
12 getAllowedExtensions,
13 getCwd,
14 getExt,
15 convertToLocalFileSystemPath,
16} = require('./path');
17const { fetchFileStream, download } = require('./api/fileMapper');
18const {
19 Mode,
20 MODULE_EXTENSION,
21 FUNCTIONS_EXTENSION,
22} = require('./lib/constants');
23
24const queue = new PQueue({
25 concurrency: 10,
26});
27
28/**
29 * @private
30 * @param {string} filepath
31 * @returns {boolean}
32 */
33function isPathToFile(filepath) {
34 const ext = getExt(filepath);
35 return !!ext && ext !== MODULE_EXTENSION && ext !== FUNCTIONS_EXTENSION;
36}
37
38/**
39 * @private
40 * @param {string} filepath
41 * @returns {boolean}
42 */
43function isPathToModule(filepath) {
44 const ext = getExt(filepath);
45 return ext === MODULE_EXTENSION;
46}
47
48/**
49 * @private
50 * @param {string} filepath
51 * @returns {boolean}
52 */
53function isPathToRoot(filepath) {
54 if (typeof filepath !== 'string') return false;
55 // Root pattern matches empty strings and: / \
56 return /^(\/|\\)?$/.test(filepath.trim());
57}
58
59/**
60 * @private
61 * @param {string} filepath
62 * @returns {boolean}
63 */
64function isAllowedExtension(filepath) {
65 const ext = getExt(filepath);
66 if (!ext) return false;
67 return getAllowedExtensions().has(ext);
68}
69
70/**
71 * Determines API `buffer` param based on mode.
72 *
73 * @param {Mode} mode
74 */
75function useApiBuffer(mode) {
76 return mode === Mode.draft;
77}
78
79/**
80 * @param {Mode} mode
81 */
82function getFileMapperApiQueryFromMode(mode) {
83 return {
84 buffer: useApiBuffer(mode),
85 };
86}
87
88/**
89 * TODO: Replace with TypeScript interface.
90 * @typedef {Object} FileMapperNode A tree node from the filemapper API.
91 * @property {string} path - Directory or file path.
92 * @property {string|null} source - File source contents.
93 * @property {number} id
94 * @property {number} createdAt
95 * @property {number} updatedAt
96 * @property {FileMapperNode[]} children
97 * @property {string} parentPath - Directory path of parent.
98 * @property {boolean} folder - True if a folder, false otherwise.
99 * @property {string} name - Name of file.
100 */
101
102/**
103 * @private
104 * @param {FileMapperNode} node
105 * @throws {TypeError}
106 */
107function validateFileMapperNode(node) {
108 if (node === Object(node)) return;
109 let json;
110 try {
111 json = JSON.stringify(node, null, 2);
112 } catch (err) {
113 json = node;
114 }
115 throw new TypeError(`Invalid FileMapperNode: ${json}`);
116}
117
118/**
119 * @callback recurseFileMapperNodeCallback
120 * @param {FileMapperNode} node
121 * @param {Object} options
122 * @param {number} options.depth
123 * @param {string} options.filepath
124 * @returns {boolean} `false` to exit recursion.
125 */
126
127/**
128 * @typedef {Object} FileMapperNodeMeta
129 * @property {boolean} isBuiltin
130 * @property {boolean} isModule
131 */
132
133/**
134 * @private
135 * @param {string} src
136 * @returns {object<boolean, boolean, boolean, boolean}
137 */
138function getTypeDataFromPath(src) {
139 const isModule = isPathToModule(src);
140 const isFile = !isModule && isPathToFile(src);
141 const isRoot = !isModule && !isFile && isPathToRoot(src);
142 const isFolder = !isFile;
143 return {
144 isModule,
145 isFile,
146 isRoot,
147 isFolder,
148 };
149}
150
151/**
152 * @typedef {Object} FileMapperInputArguments
153 * @property {number} portalId
154 * @property {string} src
155 * @property {string} dest
156 * @property {string} mode
157 * @property {object} options
158 */
159
160/**
161 * Recurse a FileMapperNode tree.
162 *
163 * @private
164 * @param {FileMapperNode} node
165 * @param {recurseFileMapperNodeCallback} callback
166 * @throws {Error}
167 */
168function recurseFolder(node, callback, filepath = '', depth = 0) {
169 validateFileMapperNode(node);
170 const isRootFolder = node.folder && depth === 0;
171 if (isRootFolder) {
172 if (!filepath) {
173 filepath = node.name;
174 }
175 } else {
176 filepath = path.join(filepath, node.name);
177 }
178 let __break = callback(node, { filepath, depth });
179 if (__break === false) return __break;
180 __break = node.children.every(childNode => {
181 __break = recurseFolder(childNode, callback, filepath, depth + 1);
182 return __break !== false;
183 });
184 return depth === 0 ? undefined : __break;
185}
186
187/**
188 * @private
189 * @async
190 * @param {FileMapperInputArguments} input
191 * @param {string} filepath
192 * @param {FileMapperNode} node
193 * @returns {Promise}
194 */
195async function writeUtimes(input, filepath, node) {
196 try {
197 const now = new Date();
198 const atime = node.createdAt ? new Date(node.createdAt) : now;
199 const mtime = node.updatedAt ? new Date(node.updatedAt) : now;
200 await fs.utimes(filepath, atime, mtime);
201 } catch (err) {
202 logFileSystemErrorInstance(
203 err,
204 new FileSystemErrorContext({
205 filepath,
206 portalId: input.portalId,
207 write: true,
208 })
209 );
210 }
211}
212
213/**
214 * @private
215 * @async
216 * @param {FileMapperInputArguments} input
217 * @param {string} filepath
218 * @returns {Promise<boolean}
219 */
220async function skipExisting(input, filepath) {
221 if (input.options.overwrite) {
222 return false;
223 }
224 if (await fs.pathExists(filepath)) {
225 logger.log('Skipped existing "%s"', filepath);
226 return true;
227 }
228 return false;
229}
230
231/**
232 * @private
233 * @async
234 * @param {FileMapperInputArguments} input
235 * @param {string} srcPath - Server path to download.
236 * @param {string} filepath - Local path to write to.
237 */
238async function fetchAndWriteFileStream(input, srcPath, filepath) {
239 if (typeof srcPath !== 'string' || !srcPath.trim()) {
240 // This avoids issue where API was returning v1 modules with `path: ""`
241 return null;
242 }
243 if (await skipExisting(input, filepath)) {
244 return null;
245 }
246 if (!isAllowedExtension(srcPath)) {
247 const message = `Invalid file type requested: "${srcPath}"`;
248 logger.error(message);
249 throw new Error(message);
250 }
251 const { portalId } = input;
252 const logFsError = err => {
253 logFileSystemErrorInstance(
254 err,
255 new FileSystemErrorContext({
256 filepath,
257 portalId,
258 write: true,
259 })
260 );
261 };
262 let writeStream;
263 try {
264 await fs.ensureFile(filepath);
265 writeStream = fs.createWriteStream(filepath, { encoding: 'binary' });
266 } catch (err) {
267 logFsError(err);
268 throw err;
269 }
270 let node;
271 try {
272 node = await fetchFileStream(portalId, srcPath, writeStream, {
273 qs: getFileMapperApiQueryFromMode(input.mode),
274 });
275 } catch (err) {
276 logApiErrorInstance(
277 err,
278 new ApiErrorContext({
279 portalId,
280 request: srcPath,
281 })
282 );
283 throw err;
284 }
285 return new Promise((resolve, reject) => {
286 writeStream.on('error', err => {
287 logFsError(err);
288 reject(err);
289 });
290 writeStream.on('close', async () => {
291 await writeUtimes(input, filepath, node);
292 logger.log('Wrote file "%s"', filepath);
293 resolve(node);
294 });
295 });
296}
297
298/**
299 * Writes an individual file or folder (not recursive). If file source is missing, the
300 * file is fetched.
301 *
302 * @private
303 * @async
304 * @param {FileMapperInputArguments} input
305 * @param {FileMapperNode} node
306 * @param {string} filepath
307 * @returns {Promise}
308 */
309async function writeFileMapperNode(input, node, filepath) {
310 filepath = convertToLocalFileSystemPath(path.resolve(filepath));
311 if (await skipExisting(input, filepath)) {
312 return;
313 }
314 if (!node.folder) {
315 try {
316 await fetchAndWriteFileStream(input, node.path, filepath);
317 } catch (err) {
318 // Logging handled by handler
319 }
320 return;
321 }
322 try {
323 await fs.ensureDir(filepath);
324 logger.log('Wrote folder "%s"', filepath);
325 } catch (err) {
326 logFileSystemErrorInstance(
327 err,
328 new FileSystemErrorContext({
329 filepath,
330 portalId: input.portalId,
331 write: true,
332 })
333 );
334 }
335}
336
337/**
338 * @private
339 * @async
340 * @param {FileMapperInputArguments} input
341 * @returns {Promise}
342 */
343async function downloadFile(input) {
344 try {
345 const { src } = input;
346 const { isFile } = getTypeDataFromPath(src);
347 if (!isFile) {
348 throw new Error(`Invalid request for file: "${src}"`);
349 }
350 const dest = path.resolve(input.dest);
351 const cwd = getCwd();
352 let filepath;
353 if (dest === cwd) {
354 // Dest: CWD
355 filepath = path.resolve(cwd, path.basename(src));
356 } else if (isPathToFile(dest)) {
357 // Dest: file path
358 filepath = path.isAbsolute(dest) ? dest : path.resolve(cwd, dest);
359 } else {
360 // Dest: folder path
361 const name = path.basename(src);
362 filepath = path.isAbsolute(dest)
363 ? path.resolve(dest, name)
364 : path.resolve(cwd, dest, name);
365 }
366 const localFsPath = convertToLocalFileSystemPath(filepath);
367 await fetchAndWriteFileStream(input, input.src, localFsPath);
368 await queue.onIdle();
369 logger.log('Completed fetch of file "%s" to "%s"', input.src, localFsPath);
370 } catch (err) {
371 logger.error('Failed fetch of file "%s" to "%s"', input.src, input.dest);
372 }
373}
374
375/**
376 * @private
377 * @async
378 * @param {FileMapperInputArguments} input
379 * @returns {Promise<FileMapperNode}
380 */
381async function fetchFolderFromApi(input) {
382 const { portalId, src, mode } = input;
383 const { isRoot, isFolder } = getTypeDataFromPath(src);
384 if (!isFolder) {
385 throw new Error(`Invalid request for folder: "${src}"`);
386 }
387 try {
388 const srcPath = isRoot ? '@root' : src;
389 const node = await download(portalId, srcPath, {
390 qs: getFileMapperApiQueryFromMode(mode),
391 });
392 logger.log('Fetched "%s" from portal %d successfully', src, portalId);
393 return node;
394 } catch (err) {
395 logApiErrorInstance(
396 err,
397 new ApiErrorContext({
398 portalId,
399 request: src,
400 })
401 );
402 }
403 return null;
404}
405
406/**
407 * @private
408 * @async
409 * @param {FileMapperInputArguments} input
410 * @returns {Promise}
411 */
412async function downloadFolder(input) {
413 try {
414 const node = await fetchFolderFromApi(input);
415 if (!node) {
416 return;
417 }
418 const dest = path.resolve(input.dest);
419 const rootPath =
420 dest === getCwd()
421 ? convertToLocalFileSystemPath(path.resolve(dest, node.name))
422 : dest;
423 recurseFolder(
424 node,
425 (childNode, { filepath }) => {
426 queue.add(() => writeFileMapperNode(input, childNode, filepath));
427 },
428 rootPath
429 );
430 await queue.onIdle();
431 logger.log('Completed fetch of folder "%s" to "%s"', input.src, input.dest);
432 } catch (err) {
433 logger.error('Failed fetch of folder "%s" to "%s"', input.src, input.dest);
434 }
435}
436
437/**
438 * Fetch a file/folder and write to local file system.
439 *
440 * @async
441 * @param {FileMapperInputArguments} input
442 * @returns {Promise}
443 */
444async function downloadFileOrFolder(input) {
445 try {
446 if (!(input && input.src)) {
447 return;
448 }
449 const { isFile } = getTypeDataFromPath(input.src);
450 if (isFile) {
451 await downloadFile(input);
452 } else {
453 await downloadFolder(input);
454 }
455 } catch (err) {
456 // Specific handlers provide logging.
457 }
458}
459
460module.exports = {
461 isPathToFile,
462 isPathToModule,
463 isPathToRoot,
464 downloadFileOrFolder,
465 recurseFolder,
466 getFileMapperApiQueryFromMode,
467 fetchFolderFromApi,
468 getTypeDataFromPath,
469};