@shockpkg/dir-projector
Version:
Package for creating Shockwave Director projectors
486 lines (445 loc) • 12.2 kB
JavaScript
import { createReadStream, createWriteStream } from 'node:fs';
import { chmod, lstat, mkdir, readdir, readlink, stat, symlink, utimes } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { join as pathJoin, dirname, basename, resolve } from 'node:path';
import { fsLchmod, fsLutimes, fsWalk, fsLstatExists } from '@shockpkg/archive-files';
import { Queue } from "./queue.mjs";
const userExec = 0b001000000;
/**
* Options for adding resources.
*/
/**
* Bundle object.
*/
export class Bundle {
/**
* File and directory names to exclude when adding a directory.
*/
// eslint-disable-next-line unicorn/better-regex
excludes = [/^\./, /^ehthumbs\.db$/i, /^Thumbs\.db$/i];
/**
* Bundle main executable path.
*/
/**
* Flat bundle.
*/
/**
* Projector instance.
*/
/**
* Open flag.
*/
_isOpen = false;
/**
* Close callbacks priority queue.
*/
_closeQueue = new Queue();
/**
* Bundle constructor.
*
* @param path Output path for the main executable.
* @param flat Flat bundle.
*/
constructor(path, flat = false) {
this.path = path;
this.flat = flat;
}
/**
* Check if output open.
*
* @returns Returns true if open, else false.
*/
get isOpen() {
return this._isOpen;
}
/**
* Check if name is excluded file.
*
* @param name File name.
* @returns Returns true if excluded, else false.
*/
isExcludedFile(name) {
for (const exclude of this.excludes) {
if (exclude.test(name)) {
return true;
}
}
return false;
}
/**
* Open output.
*/
async open() {
if (this._isOpen) {
throw new Error('Already open');
}
await this._checkOutput();
this._closeQueue.clear();
await this._open();
this._isOpen = true;
}
/**
* Close output.
*/
async close() {
this._assertIsOpen();
try {
await this._close();
} finally {
this._closeQueue.clear();
}
this._isOpen = false;
}
/**
* Write out the bundle.
* Has a callback to write out the resources.
*
* @param func Async function.
* @returns Return value of the async function.
*/
async write(func = null) {
await this.open();
try {
return func ? await func.call(this, this) : null;
} finally {
await this.close();
}
}
/**
* Get path for resource.
*
* @param destination Resource destination.
* @returns Destination path.
*/
resourcePath(destination) {
return pathJoin(dirname(this.projector.path), destination);
}
/**
* Check if path for resource exists.
*
* @param destination Resource destination.
* @returns True if destination exists, else false.
*/
async resourceExists(destination) {
return !!(await fsLstatExists(this.resourcePath(destination)));
}
/**
* Copy resource, detecting source type automatically.
*
* @param destination Resource destination.
* @param source Source directory.
* @param options Resource options.
*/
async copyResource(destination, source, options = null) {
this._assertIsOpen();
const stat = await lstat(source);
switch (true) {
case stat.isSymbolicLink():
{
await this.copyResourceSymlink(destination, source, options);
break;
}
case stat.isFile():
{
await this.copyResourceFile(destination, source, options);
break;
}
case stat.isDirectory():
{
await this.copyResourceDirectory(destination, source, options);
break;
}
default:
{
throw new Error(`Unsupported resource type: ${source}`);
}
}
}
/**
* Copy directory as resource, recursive copy.
*
* @param destination Resource destination.
* @param source Source directory.
* @param options Resource options.
*/
async copyResourceDirectory(destination, source, options = null) {
this._assertIsOpen();
// Create directory.
await this.createResourceDirectory(destination, options ? await this._expandResourceOptionsCopy(options, async () => stat(source)) : options);
// If not recursive do not walk contents.
if (options && options.noRecurse) {
return;
}
// Any directories we add should not be recursive.
const opts = {
...options,
noRecurse: true
};
await fsWalk(source, async (path, stat) => {
// If this name is excluded, skip without descending.
if (this.isExcludedFile(basename(path))) {
return false;
}
await this.copyResource(pathJoin(destination, path), pathJoin(source, path), opts);
return true;
});
}
/**
* Copy file as resource.
*
* @param destination Resource destination.
* @param source Source file.
* @param options Resource options.
*/
async copyResourceFile(destination, source, options = null) {
this._assertIsOpen();
await this.streamResourceFile(destination, createReadStream(source), options ? await this._expandResourceOptionsCopy(options, async () => stat(source)) : options);
}
/**
* Copy symlink as resource.
*
* @param destination Resource destination.
* @param source Source symlink.
* @param options Resource options.
*/
async copyResourceSymlink(destination, source, options = null) {
this._assertIsOpen();
await this.createResourceSymlink(destination, await readlink(source), options ? await this._expandResourceOptionsCopy(options, async () => lstat(source)) : options);
}
/**
* Create a resource directory.
*
* @param destination Resource destination.
* @param options Resource options.
*/
async createResourceDirectory(destination, options = null) {
this._assertIsOpen();
const dest = await this._assertNotResourceExists(destination, !!(options && options.merge));
await mkdir(dest, {
recursive: true
});
// If either is set, queue up change times when closing.
if (options && (options.atime || options.mtime)) {
// Get absolute path, use length for the priority.
// Also copy the options object which the owner could change.
const abs = resolve(dest);
this._closeQueue.push(this._setResourceAttributes.bind(this, abs, {
...options
}), abs.length);
}
}
/**
* Create a resource file.
*
* @param destination Resource destination.
* @param data Resource data.
* @param options Resource options.
*/
async createResourceFile(destination, data, options = null) {
this._assertIsOpen();
await this.streamResourceFile(destination, new Readable({
/**
* Read method.
*/
read() {
this.push(data);
this.push(null);
}
}), options);
}
/**
* Create a resource symlink.
*
* @param destination Resource destination.
* @param target Symlink target.
* @param options Resource options.
*/
async createResourceSymlink(destination, target, options = null) {
this._assertIsOpen();
const dest = await this._assertNotResourceExists(destination);
await mkdir(dirname(dest), {
recursive: true
});
const t = typeof target === 'string' ? target : Buffer.from(target.buffer, target.byteOffset, target.byteLength);
await symlink(t, dest);
if (options) {
await this._setResourceAttributes(dest, options);
}
}
/**
* Stream readable source to resource file.
*
* @param destination Resource destination.
* @param data Resource stream.
* @param options Resource options.
*/
async streamResourceFile(destination, data, options = null) {
this._assertIsOpen();
const dest = await this._assertNotResourceExists(destination);
await mkdir(dirname(dest), {
recursive: true
});
await pipeline(data, createWriteStream(dest));
if (options) {
await this._setResourceAttributes(dest, options);
}
}
/**
* Check that output path is valid, else throws.
*/
async _checkOutput() {
if (this.flat) {
const p = dirname(this.path);
if (await fsLstatExists(p)) {
for (const n of await readdir(p)) {
if (!this.isExcludedFile(n)) {
throw new Error(`Output path not empty: ${p}`);
}
}
}
return;
}
await Promise.all([this.path, this.resourcePath('')].map(async p => {
if (await fsLstatExists(p)) {
throw new Error(`Output path already exists: ${p}`);
}
}));
}
/**
* Expand resource options copy properties with stat object from source.
*
* @param options Options object.
* @param stat Stat function.
* @returns Options copy with any values populated.
*/
async _expandResourceOptionsCopy(options, stat) {
const r = {
...options
};
let st;
if (!r.atime && r.atimeCopy) {
st = await stat();
r.atime = st.atime;
}
if (!r.mtime && r.mtimeCopy) {
st ??= await stat();
r.mtime = st.mtime;
}
if (typeof r.executable !== 'boolean' && r.executableCopy) {
st ??= await stat();
r.executable = this._getResourceModeExecutable(st.mode);
}
return r;
}
/**
* Set resource attributes from options object.
*
* @param path File path.
* @param options Options object.
*/
async _setResourceAttributes(path, options) {
const {
atime,
mtime,
executable
} = options;
const st = await lstat(path);
// Maybe set executable if not a directory.
if (typeof executable === 'boolean' && !st.isDirectory()) {
// eslint-disable-next-line unicorn/prefer-ternary
if (st.isSymbolicLink()) {
await fsLchmod(path, this._setResourceModeExecutable(
// Workaround for a legacy Node issue.
// eslint-disable-next-line no-bitwise
st.mode & 0b111111111, executable));
} else {
await chmod(path, this._setResourceModeExecutable(st.mode, executable));
}
}
// Maybe change times if either is set.
if (atime || mtime) {
// eslint-disable-next-line unicorn/prefer-ternary
if (st.isSymbolicLink()) {
await fsLutimes(path, atime || st.atime, mtime || st.mtime);
} else {
await utimes(path, atime || st.atime, mtime || st.mtime);
}
}
}
/**
* Get file mode executable.
*
* @param mode Current mode.
* @returns Is executable.
*/
_getResourceModeExecutable(mode) {
// eslint-disable-next-line no-bitwise
return !!(mode & userExec);
}
/**
* Set file mode executable.
*
* @param mode Current mode.
* @param executable Is executable.
* @returns File mode.
*/
_setResourceModeExecutable(mode, executable) {
// eslint-disable-next-line no-bitwise
return (executable ? mode | userExec : mode & ~userExec) >>> 0;
}
/**
* Open output.
*/
async _open() {
await this.projector.write();
}
/**
* Close output.
*/
async _close() {
await this._closeQueue.run();
}
/**
* Assert bundle is open.
*/
_assertIsOpen() {
if (!this._isOpen) {
throw new Error('Not open');
}
}
/**
* Assert resource does not exist, returning destination path.
*
* @param destination Resource destination.
* @param ignoreDirectory Ignore directories.
* @returns Destination path.
*/
async _assertNotResourceExists(destination, ignoreDirectory = false) {
const dest = this.resourcePath(destination);
const st = await fsLstatExists(dest);
if (st && (!ignoreDirectory || !st.isDirectory())) {
throw new Error(`Resource path exists: ${dest}`);
}
return dest;
}
/**
* Get the projector path.
*
* @returns This path or the nested path.
*/
_getProjectorPath() {
return this.flat ? this.path : this._getProjectorPathNested();
}
/**
* Get nested projector path.
*
* @returns Output path.
*/
/**
* Create projector instance for the bundle.
*
* @returns Projector instance.
*/
}
//# sourceMappingURL=bundle.mjs.map