UNPKG

12 kBJavaScriptView Raw
1import { createReadStream, createWriteStream } from 'node:fs';
2import { chmod, lstat, mkdir, readdir, readlink, stat, symlink, utimes } from 'node:fs/promises';
3import { Readable } from 'node:stream';
4import { pipeline } from 'node:stream/promises';
5import { join as pathJoin, dirname, basename, resolve } from 'node:path';
6import { fsLchmod, fsLutimes, fsWalk, fsLstatExists } from '@shockpkg/archive-files';
7import { Queue } from "./queue.mjs";
8const userExec = 0b001000000;
9
10/**
11 * Options for adding resources.
12 */
13
14/**
15 * Bundle object.
16 */
17export class Bundle {
18 /**
19 * File and directory names to exclude when adding a directory.
20 */
21 excludes = [/^\./, /^ehthumbs\.db$/i, /^Thumbs\.db$/i];
22
23 /**
24 * Bundle main executable path.
25 */
26
27 /**
28 * Flat bundle.
29 */
30
31 /**
32 * Projector instance.
33 */
34
35 /**
36 * Open flag.
37 */
38 _isOpen = false;
39
40 /**
41 * Close callbacks priority queue.
42 */
43 _closeQueue = new Queue();
44
45 /**
46 * Bundle constructor.
47 *
48 * @param path Output path for the main executable.
49 * @param flat Flat bundle.
50 */
51 constructor(path, flat = false) {
52 this.path = path;
53 this.flat = flat;
54 }
55
56 /**
57 * Check if output open.
58 *
59 * @returns Returns true if open, else false.
60 */
61 get isOpen() {
62 return this._isOpen;
63 }
64
65 /**
66 * Check if name is excluded file.
67 *
68 * @param name File name.
69 * @returns Returns true if excluded, else false.
70 */
71 isExcludedFile(name) {
72 for (const exclude of this.excludes) {
73 if (exclude.test(name)) {
74 return true;
75 }
76 }
77 return false;
78 }
79
80 /**
81 * Open output.
82 */
83 async open() {
84 if (this._isOpen) {
85 throw new Error('Already open');
86 }
87 await this._checkOutput();
88 this._closeQueue.clear();
89 await this._open();
90 this._isOpen = true;
91 }
92
93 /**
94 * Close output.
95 */
96 async close() {
97 this._assertIsOpen();
98 try {
99 await this._close();
100 } finally {
101 this._closeQueue.clear();
102 }
103 this._isOpen = false;
104 }
105
106 /**
107 * Write out the bundle.
108 * Has a callback to write out the resources.
109 *
110 * @param func Async function.
111 * @returns Return value of the async function.
112 */
113 async write(func = null) {
114 await this.open();
115 try {
116 return func ? await func.call(this, this) : null;
117 } finally {
118 await this.close();
119 }
120 }
121
122 /**
123 * Get path for resource.
124 *
125 * @param destination Resource destination.
126 * @returns Destination path.
127 */
128 resourcePath(destination) {
129 return pathJoin(dirname(this.projector.path), destination);
130 }
131
132 /**
133 * Check if path for resource exists.
134 *
135 * @param destination Resource destination.
136 * @returns True if destination exists, else false.
137 */
138 async resourceExists(destination) {
139 return !!(await fsLstatExists(this.resourcePath(destination)));
140 }
141
142 /**
143 * Copy resource, detecting source type automatically.
144 *
145 * @param destination Resource destination.
146 * @param source Source directory.
147 * @param options Resource options.
148 */
149 async copyResource(destination, source, options = null) {
150 this._assertIsOpen();
151 const stat = await lstat(source);
152 switch (true) {
153 case stat.isSymbolicLink():
154 {
155 await this.copyResourceSymlink(destination, source, options);
156 break;
157 }
158 case stat.isFile():
159 {
160 await this.copyResourceFile(destination, source, options);
161 break;
162 }
163 case stat.isDirectory():
164 {
165 await this.copyResourceDirectory(destination, source, options);
166 break;
167 }
168 default:
169 {
170 throw new Error(`Unsupported resource type: ${source}`);
171 }
172 }
173 }
174
175 /**
176 * Copy directory as resource, recursive copy.
177 *
178 * @param destination Resource destination.
179 * @param source Source directory.
180 * @param options Resource options.
181 */
182 async copyResourceDirectory(destination, source, options = null) {
183 this._assertIsOpen();
184
185 // Create directory.
186 await this.createResourceDirectory(destination, options ? await this._expandResourceOptionsCopy(options, async () => stat(source)) : options);
187
188 // If not recursive do not walk contents.
189 if (options && options.noRecurse) {
190 return;
191 }
192
193 // Any directories we add should not be recursive.
194 const opts = {
195 ...(options || {}),
196 noRecurse: true
197 };
198 await fsWalk(source, async (path, stat) => {
199 // If this name is excluded, skip without descending.
200 if (this.isExcludedFile(basename(path))) {
201 return false;
202 }
203 await this.copyResource(pathJoin(destination, path), pathJoin(source, path), opts);
204 return true;
205 });
206 }
207
208 /**
209 * Copy file as resource.
210 *
211 * @param destination Resource destination.
212 * @param source Source file.
213 * @param options Resource options.
214 */
215 async copyResourceFile(destination, source, options = null) {
216 this._assertIsOpen();
217 await this.streamResourceFile(destination, createReadStream(source), options ? await this._expandResourceOptionsCopy(options, async () => stat(source)) : options);
218 }
219
220 /**
221 * Copy symlink as resource.
222 *
223 * @param destination Resource destination.
224 * @param source Source symlink.
225 * @param options Resource options.
226 */
227 async copyResourceSymlink(destination, source, options = null) {
228 this._assertIsOpen();
229 await this.createResourceSymlink(destination, await readlink(source), options ? await this._expandResourceOptionsCopy(options, async () => lstat(source)) : options);
230 }
231
232 /**
233 * Create a resource directory.
234 *
235 * @param destination Resource destination.
236 * @param options Resource options.
237 */
238 async createResourceDirectory(destination, options = null) {
239 this._assertIsOpen();
240 const dest = await this._assertNotResourceExists(destination, !!(options && options.merge));
241 await mkdir(dest, {
242 recursive: true
243 });
244
245 // If either is set, queue up change times when closing.
246 if (options && (options.atime || options.mtime)) {
247 // Get absolute path, use length for the priority.
248 // Also copy the options object which the owner could change.
249 const abs = resolve(dest);
250 this._closeQueue.push(this._setResourceAttributes.bind(this, abs, {
251 ...options
252 }), abs.length);
253 }
254 }
255
256 /**
257 * Create a resource file.
258 *
259 * @param destination Resource destination.
260 * @param data Resource data.
261 * @param options Resource options.
262 */
263 async createResourceFile(destination, data, options = null) {
264 this._assertIsOpen();
265 await this.streamResourceFile(destination, new Readable({
266 /**
267 * Read method.
268 */
269 read() {
270 this.push(data);
271 this.push(null);
272 }
273 }), options);
274 }
275
276 /**
277 * Create a resource symlink.
278 *
279 * @param destination Resource destination.
280 * @param target Symlink target.
281 * @param options Resource options.
282 */
283 async createResourceSymlink(destination, target, options = null) {
284 this._assertIsOpen();
285 const dest = await this._assertNotResourceExists(destination);
286 await mkdir(dirname(dest), {
287 recursive: true
288 });
289 const t = typeof target === 'string' ? target : Buffer.from(target.buffer, target.byteOffset, target.byteLength);
290 await symlink(t, dest);
291 if (options) {
292 await this._setResourceAttributes(dest, options);
293 }
294 }
295
296 /**
297 * Stream readable source to resource file.
298 *
299 * @param destination Resource destination.
300 * @param data Resource stream.
301 * @param options Resource options.
302 */
303 async streamResourceFile(destination, data, options = null) {
304 this._assertIsOpen();
305 const dest = await this._assertNotResourceExists(destination);
306 await mkdir(dirname(dest), {
307 recursive: true
308 });
309 await pipeline(data, createWriteStream(dest));
310 if (options) {
311 await this._setResourceAttributes(dest, options);
312 }
313 }
314
315 /**
316 * Check that output path is valid, else throws.
317 */
318 async _checkOutput() {
319 if (this.flat) {
320 const p = dirname(this.path);
321 if (await fsLstatExists(p)) {
322 for (const n of await readdir(p)) {
323 if (!this.isExcludedFile(n)) {
324 throw new Error(`Output path not empty: ${p}`);
325 }
326 }
327 }
328 return;
329 }
330 await Promise.all([this.path, this.resourcePath('')].map(async p => {
331 if (await fsLstatExists(p)) {
332 throw new Error(`Output path already exists: ${p}`);
333 }
334 }));
335 }
336
337 /**
338 * Expand resource options copy properties with stat object from source.
339 *
340 * @param options Options object.
341 * @param stat Stat function.
342 * @returns Options copy with any values populated.
343 */
344 async _expandResourceOptionsCopy(options, stat) {
345 const r = {
346 ...options
347 };
348 let st;
349 if (!r.atime && r.atimeCopy) {
350 st = await stat();
351 r.atime = st.atime;
352 }
353 if (!r.mtime && r.mtimeCopy) {
354 st ??= await stat();
355 r.mtime = st.mtime;
356 }
357 if (typeof r.executable !== 'boolean' && r.executableCopy) {
358 st ??= await stat();
359 r.executable = this._getResourceModeExecutable(st.mode);
360 }
361 return r;
362 }
363
364 /**
365 * Set resource attributes from options object.
366 *
367 * @param path File path.
368 * @param options Options object.
369 */
370 async _setResourceAttributes(path, options) {
371 const {
372 atime,
373 mtime,
374 executable
375 } = options;
376 const st = await lstat(path);
377
378 // Maybe set executable if not a directory.
379 if (typeof executable === 'boolean' && !st.isDirectory()) {
380 if (st.isSymbolicLink()) {
381 await fsLchmod(path, this._setResourceModeExecutable(
382 // Workaround for a legacy Node issue.
383 // eslint-disable-next-line no-bitwise
384 st.mode & 0b111111111, executable));
385 } else {
386 await chmod(path, this._setResourceModeExecutable(st.mode, executable));
387 }
388 }
389
390 // Maybe change times if either is set.
391 if (atime || mtime) {
392 if (st.isSymbolicLink()) {
393 await fsLutimes(path, atime || st.atime, mtime || st.mtime);
394 } else {
395 await utimes(path, atime || st.atime, mtime || st.mtime);
396 }
397 }
398 }
399
400 /**
401 * Get file mode executable.
402 *
403 * @param mode Current mode.
404 * @returns Is executable.
405 */
406 _getResourceModeExecutable(mode) {
407 // eslint-disable-next-line no-bitwise
408 return !!(mode & userExec);
409 }
410
411 /**
412 * Set file mode executable.
413 *
414 * @param mode Current mode.
415 * @param executable Is executable.
416 * @returns File mode.
417 */
418 _setResourceModeExecutable(mode, executable) {
419 // eslint-disable-next-line no-bitwise
420 return (executable ? mode | userExec : mode & ~userExec) >>> 0;
421 }
422
423 /**
424 * Open output.
425 */
426 async _open() {
427 await this.projector.write();
428 }
429
430 /**
431 * Close output.
432 */
433 async _close() {
434 await this._closeQueue.run();
435 }
436
437 /**
438 * Assert bundle is open.
439 */
440 _assertIsOpen() {
441 if (!this._isOpen) {
442 throw new Error('Not open');
443 }
444 }
445
446 /**
447 * Assert resource does not exist, returning destination path.
448 *
449 * @param destination Resource destination.
450 * @param ignoreDirectory Ignore directories.
451 * @returns Destination path.
452 */
453 async _assertNotResourceExists(destination, ignoreDirectory = false) {
454 const dest = this.resourcePath(destination);
455 const st = await fsLstatExists(dest);
456 if (st && (!ignoreDirectory || !st.isDirectory())) {
457 throw new Error(`Resource path exists: ${dest}`);
458 }
459 return dest;
460 }
461
462 /**
463 * Get the projector path.
464 *
465 * @returns This path or the nested path.
466 */
467 _getProjectorPath() {
468 return this.flat ? this.path : this._getProjectorPathNested();
469 }
470
471 /**
472 * Get nested projector path.
473 *
474 * @returns Output path.
475 */
476
477 /**
478 * Create projector instance for the bundle.
479 *
480 * @returns Projector instance.
481 */
482}
483//# sourceMappingURL=bundle.mjs.map
\No newline at end of file