Initial version

This commit is contained in:
Miel Truyen 2019-12-23 13:06:33 +01:00
commit 377567bde0
45 changed files with 6857 additions and 0 deletions

7
.npmignore Normal file
View File

@ -0,0 +1,7 @@
# Don't publish the src containing ESNext proposal's code. Only publish the bundled output in dist/ and the ES6-transpiled src from lib/
.idea/*
tests/*
node_modules/*
rollup.config.js
yarn.lock
yarn-error.log

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "@cerxes/host",
"version": "0.0.1",
"author": "Miel Truyen <miel.truyen@cerxes.net>",
"description": "A JS-interface to the host-machine. Provides functions for common file-system tasks during build, watch or deployment such as cleaning a dist dir, copying assets etc.",
"repository": {
"type": "git",
"url": "https://git.cerxes.net/cerxes/host.git"
},
"devDependencies": {
"@babel/types": "latest",
"@babel/register": "latest",
"@babel/cli": "latest",
"@babel/core": "latest",
"@babel/plugin-proposal-class-properties": "latest",
"@babel/plugin-proposal-decorators": "latest",
"@babel/plugin-proposal-export-default-from": "latest",
"@babel/plugin-proposal-export-namespace-from": "latest",
"@babel/plugin-proposal-nullish-coalescing-operator": "latest",
"@babel/plugin-proposal-optional-chaining": "latest",
"@babel/plugin-proposal-private-methods": "latest",
"@babel/preset-env": "latest",
"rollup": "latest",
"rollup-plugin-babel": "latest",
"rollup-plugin-sourcemaps": "latest",
"rollup-plugin-node-resolve": "latest",
"rollup-plugin-commonjs": "latest",
"rollup-plugin-string": "latest",
"rollup-plugin-terser": "latest",
"rollup-plugin-json": "latest",
"builtin-modules": "latest",
"jsdoc": "latest",
"jest": "latest",
"npm-run-all": "latest",
"@types/node": "^12.12.12",
"@types/zen-observable": "^0.8.0"
},
"dependencies": {
"ansi-up": "^1.0.0",
"json5": "^2.1.1",
"minimatch": "^3.0.4",
"zen-observable": "latest"
},
"scripts": {
"build": "npm-run-all -p build-cjs build-es6",
"watch": "npm-run-all -p watch-cjs watch-es6",
"build-cjs": "rollup -c",
"watch-cjs": "rollup -c -w",
"build-es6": "npx babel ./src --out-dir=lib",
"watch-es6": "npx babel ./src --out-dir=lib -w",
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net --tag latest",
"test": "jest --detectOpenHandles",
"test-manual-watch": "cd tests/manual && node -r @babel/register watch.dev.js",
"test-manual-pattern": "cd tests/manual && node -r @babel/register watch-glob.dev.js",
"test-manual-sync": "cd tests/manual && node -r @babel/register watch-sync.dev.js",
"test-manual-processArgs": "cd tests/manual && node -r @babel/register process-args.js"
},
"module": "./lib/index.js",
"main": "./dist/index.js"
}

37
rollup.config.js Normal file
View File

@ -0,0 +1,37 @@
import babel from 'rollup-plugin-babel';
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import json from "rollup-plugin-json";
// `npm run build` -> `production` is true
// `npm run dev` -> `production` is false
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'src/index.js',
output: {
file: 'dist/index.js',
format: 'cjs', // common-js bundle as would be expected for node
sourcemap: true
},
plugins: [
// Add json support (sadly in rollup we have to do this explicity, despite the NodeJS algorithm supporitng this by defulat
json(),
// babel
babel(),
// node_modules
resolve({
preferBuiltins: true,
extensions: ['.mjs', '.js', '.jsx', '.json'],
}),
// CJS-modules
commonjs({
'minimatch': ['Minimatch'],
}),
// minify, but only in production
production && terser()
],
external: [
]
};

21
src/.babelrc Normal file
View File

@ -0,0 +1,21 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-private-methods",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator",
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-export-default-from",
"@babel/plugin-syntax-dynamic-import"
]
}

36
src/helpers/fs-node.js Normal file
View File

@ -0,0 +1,36 @@
/**
* Describes a node in the file-system
*
* @class
*/
export class FsNode{
/**
* @constructor
* @param {FsNode} c
*/
constructor(c){
if(c){
this.file = c.file;
this.path = c.path;
this.stats = c.stats;
}
}
/**
* Relative path of the node
* @type {string}
*/
file;
/**
* Absolute path of the node
* @type {string}
*/
path;
/**
* Relative path of the node
* @type {module:fs.Stats}
*/
stats;
}

131
src/helpers/fs-promises.js Normal file
View File

@ -0,0 +1,131 @@
import fs from "fs";
// Common/General
/**
* Get file status.
*
* @param {module:fs.PathLike} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<module:fs.Stats>}
*/
export async function stat(path){
return await new Promise((resolve, reject) => fs.stat(path, (err, stats) => err ? reject(err) : resolve(stats)));
}
/**
* Similar to stat except this will return null, when the file does not exist instead of throw an error
*
* @param {module:fs.PathLike} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<module:fs.Stats>}
*/
export async function tryStat(path){
return await new Promise((resolve, reject) => fs.stat(path, (err, stats) => err && err.code!=='ENOENT' ? reject(err) : resolve(stats||null)));
}
// Directories
/**
* Asynchronous mkdir(2) - create a directory.
*
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @param {Object} [options] - Either the file mode, or an object optionally specifying the file mode and whether parent folders
* should be created. If a string is passed, it is parsed as an octal integer. If not specified, defaults to `0o777`.
* @param {boolean} [options.recursive] - Indicates whether parent folders should be created.
* @param {string} [options.mode] - A file mode. If a string is passed, it is parsed as an octal integer. If not specified 0o777 is used as a default
* @return {Promise<void>}
*/
export async function mkdir(path, options) {
return await new Promise((resolve, reject) =>
fs.mkdir(path, options, (err) => err ? reject(err) : resolve())
);
}
/**
* Asynchronous rmdir(2) - delete a directory.
*
* @param {string} path A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<void>}
*/
export async function rmdir(path) {
return await new Promise((resolve, reject) =>
fs.rmdir(path, (err) => err ? reject(err) : resolve())
);
}
/**
* Asynchronous readdir(3) - read a directory.
*
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @param {Object} [options] - The encoding (or an object specifying the encoding), used as the encoding of the result. If not provided, `'utf8'` is used.
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - Encoding
* @param {boolean} [options.withFileTypes] - Include file types
* @return {Promise<Array.<string>>}
*/
export async function readdir(path, options) {
return await new Promise((resolve, reject) => fs.readdir(path, options, (err, files) => err ? reject(err) : resolve(files)));
}
// Files
/**
* Asynchronous delete a name and possibly the file it refers to.
*
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<void>}
*/
export async function unlink(path) {
return await new Promise((resolve, reject) =>
fs.unlink(path, (err) => err ? reject(err) : resolve())
);
}
/**
* Read a file
*
* @param {string} path
* @param {Object} [options] - Read options, like encoding and mode
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - Encoding
* @param {string} [options.flag] - If a flag is not provided, it defaults to `'r'`.
* @return {Promise<Buffer>}
*/
export async function readFile(path, options) {
return await new Promise((resolve, reject) =>
fs.readFile(path, options, (err, data) => err ? reject(err) : resolve(data))
);
}
/**
* Asynchronously writes data to a file, replacing the file if it already exists. The underlying file will _not_ be closed automatically.
* The `FileHandle` must have been opened for writing.
* It is unsafe to call `writeFile()` multiple times on the same file without waiting for the `Promise` to be resolved (or rejected).
* @param data The data to write. If something other than a `Buffer` or `Uint8Array` is provided, the value is coerced to a string.
* @param {Object} [options] - Either the encoding for the file, or an object optionally specifying the encoding, file mode, and flag.
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - If `encoding` is not supplied, the default of `'utf8'` is used.
* @param {string} [options.mode] - If `mode` is not supplied, the default of `0o666` is used.
* If `mode` is a string, it is parsed as an octal integer.
* @param {string} [options.flag] - If `flag` is not supplied, the default of `'w'` is used.
* @return {Promise<void>}
*/
export async function writeFile(path, data, options) {
return await new Promise((resolve, reject) =>
fs.writeFile(path, data, options, (err, data) => err ? reject(err) : resolve(data))
);
}
/**
* Asynchronously copies src to dest. By default, dest is overwritten if it already exists.
* No arguments other than a possible exception are given to the callback function.
* Node.js makes no guarantees about the atomicity of the copy operation.
* If an error occurs after the destination file has been opened for writing, Node.js will attempt
* to remove the destination.
* @param {string} src A path to the source file.
* @param {string} dest A path to the destination file.
* @param {string} flags An integer that specifies the behavior of the copy operation. The only supported flag is fs.constants.COPYFILE_EXCL, which causes the copy operation to fail if dest already exists.
* @return {Promise<void>}
*/
export async function copyFile(src, dest, flags) {
return await new Promise((resolve, reject) =>
fs.copyFile(src, dest, flags, (err) => err ? reject(err) : resolve())
);
}

133
src/helpers/sync.js Normal file
View File

@ -0,0 +1,133 @@
import Observable from "zen-observable";
import { Host } from "../host";
import { WatchEvent } from "./watcher";
const IS_DEBUG = false;// Hard-code this to true to debug watch-related things
/**
* Describes an event emitted from syncing a set of files
* @class
*/
export class SyncEvent {
/**
* @constructor
* @param {SyncEvent} c
*/
constructor(c) {
if (c) {
this.type = c.type;
this.changes = c.changes;
this.files = c.files;
this.nodes = c.nodes;
}
}
/**
* Type of watch-event
*
* @type {'dicovery'|'changes'}
*/
type;
/**
* @type {Array<ChangedFile>}
*/
changes;
/**
* @type {Array<string>}
*/
files;
/**
* @type {Array<FsNode>}
*/
nodes;
}
/**
* @class
* @augments Observable<SyncEvent>
*/
export class Sync extends Observable {
/** @type {Host} */
host;
/** @type {Observable<WatchEvent>} */
source;
}
/**
* @function
* @param {Observable<WatchEvent>} watch
* @param {string} srcPath
* @param {Host} host
*/
Sync.create = (watch, srcPath, host) => {
let observers = [];
// TODO might want to sync subscription with the first/last subscriber on the watch-stream
let subscription;
let first = false;
let start = () => {
first = true;
if(!subscription) {// Make sure to never subscribe twice
subscription = watch.subscribe(async (event) => {
let errors = [];
let tasks = (
first
? event.files.map(file => {
return host.copy(host.resolve(srcPath, file), file);
})
: event.changes.map(({ event, file }) => {
return event === 'removed' ? host.remove(file) : host.copy(host.resolve(srcPath, file), file)
})
).map(x=>x.catch(err=>errors.push(err)));
first = false;
let next = new SyncEvent({
...event,
// Some other data
});
await Promise.all(tasks);
for(let err of errors){
console.error(err);
}
for (let observer of observers) {
observer.next(next);
}
});
}
};
let stop = () => {
subscription.unsubscribe();
subscription = undefined;
};
let sync = new Sync((observer) => {
observers.push(observer);
if (observers.length === 1) {
start();// Get this party started!!
}
return () => {
// On unsubscribe
observers.splice(observers.indexOf(observer), 1);
if (observers.length === 0) stop();
}
});
sync.host = host;
sync.stream = watch;
sync.stop = stop;
sync.start = start;
start();// Kickstart a sync, whether subscribe is called or not!
return sync;
};

127
src/helpers/task-token.js Normal file
View File

@ -0,0 +1,127 @@
import Observable from "zen-observable";
import process from "process";
export class CancelError extends Error{
// Just a wrapper
constructor(){
super(...arguments);
this.isCancel = true;
}
}
export class TaskToken{
constructor(parent){
this._notify = {
cancelRequested: null,
completed: null,
cancelCompleted: null
};
this._children = new Set();
this.onCancelled = new Promise((resolve)=>this._notify.cancelRequested = resolve);
this.onCompleted = new Promise((resolve)=>this._notify.completed = resolve);
if(parent){
this._parent = parent;
this._parent._children.add(this);
}
this.taskLog = [];
}
/**
* Mark task as completed
*/
complete(){
if(this._notify.cancelCompleted){
this._notify.cancelCompleted();
}else {
this.completed = true;
}
if(this._parent){
this._parent._children.delete(this);
}
this._notify.completed();
}
/**
* Indicates if task is completed
*
* @type {boolean}
*/
completed = false;
/**
* Indicates if cancellation is requested
*
* @type {boolean}
*/
cancelled = false;
/**
* Promise that triggers when cancellation is requested
*
* @type {Promise<void>}
*/
onCancelled;
/**
* Promise that notifies when task is completed
*
* @type {Promise<void>}
*/
onCompleted;
/**
* Log a message
* @param {string} message
* @param {string} type
* @param {string} tags
*/
log(message, {
type,
tags,
subTask
}){
let logItem = {
message: message,
type: type||'info',
tags: Array.from(new Set(tags||[])),
timestamp: process.hrtime(),
subTask: subTask
};
this.taskLog.push(logItem);
if(this._parent){
this._parent.log(message, {type: logItem.type, tags: logItem.tags, subTask: true});
}
}
/**
* Triggers cancellation. Completes when cancel has completed
*
* @returns {Promise<void>}
*/
async cancel(){
let ownCompletion = new Promise((resolve)=>{
this._notify.cancelCompleted = resolve;
});
// Send the cancel message
this.cancelled = true;
this._notify.cancelRequested();
// Wait for child and own completion
let childCancels = Array.from(this._children).map(c=>c.cancel());
await Promise.all([
...childCancels,// Cancel child tasks
ownCompletion
]);
}
/**
* Throw an error if task cancellation was requested
*/
throwIfCancelled(){
if(this.cancelled){
throw new CancelError("Cancel requested");
}
}
}

107
src/helpers/traverse.js Normal file
View File

@ -0,0 +1,107 @@
/*
* @module helpers/traverse
*/
// Export types used in the traverse-functionality
import * as nodePath from "path";
export const SkipNode = Symbol("skip-node");
export const SkipChildren = Symbol("skip-children");
/**
* @typedef {boolean|SkipNode|SkipChildren|null|undefined} TraverselReturn
*/
/**
* Describes the current state of traversal
*
*/
export class TraveralState{
/**
*
* @param {string} file
* @param {string} root
* @param {module:fs.Stats} stats
*/
constructor(file, root, stats){
this.file = file;
this.root = root;
this.stats = stats;
}
/**
* Relative path to the file (relative from root)
*
* @type {string}
*/
file;
/**
* Stats of the file
*
* @type {module:fs.Stats}
*/
stats;
/**
* Root path used when starting traversal, and thus the path from whice file is relative
* @type {string}
*/
root;
/**
* Indicate whether this node has been flagged as to-be-skipped (and not included in the output)
*
* @type {boolean}
*/
skipped = false;
/**
* Indicate whether this node's children have been flagged as to-be-skipped (and not included in the output)
*
* @type {boolean}
*/
skippedChildren = false;
// Getters
/**
* Absolute path to the file
*
* @return {string}
*/
get path(){
return nodePath.resolve(this.root, this.file);
}
// Methods
/**
* Indicate that this node is to be skipped and neither it, nor its children included in the output
*
* @return {SkipNode}
*/
skip(){
this.skipped = true;
return SkipNode;
}
/**
* Indicate that this node's children are to be skipped and excluded from the output
*
* @return {SkipChildren}
*/
skipChildren(){
this.skippedChildren = true;
return SkipChildren;
}
}
/**
* Callback for processing nodes when traversing a directory
*
* @callback TraveralState~traverseCallback
* @param {string} file - Config for the stats-call
* @param {TraveralState} state - Url of the resource.
* @returns {Promise<TraverselReturn>|TraverselReturn} - A truthy return value to include the file into the end results
* A falsy value to exclude it. To exclude this node and its children use the SkipNode-symbol. To include this node but exclude
* the children return the SkipChildren-symbol
*/

81
src/helpers/watch.js Normal file
View File

@ -0,0 +1,81 @@
import Observable from "zen-observable";
import minimatch from "minimatch";
import { WatchEvent, Watcher } from "./watcher";
import { Sync } from "./sync";
// We've got an annoying circular dependency with Host (and potentially with sync), which could be fixed if we define an interface that Host implements...
/**
* @class
* @augments Observable<WatchEvent>
*/
export class Watch extends Observable{
/** @type {Host} */
host;
/** @type {string} */
path;
/**
*
* @param pattern
* @returns {Watch}
*/
glob(pattern) {
let mm = new minimatch.Minimatch(pattern);
let derivedWatch = this.map(event => new WatchEvent({
...event,
changes: event.changes.filter(({ file }) => mm.match(file)),
files: event.files.filter((file) => mm.match(file)),
nodes: event.nodes.filter(({ file }) => mm.match(file)),
})).filter(event => event.type !== 'changed' || event.changes.length > 0);
derivedWatch.host = this.host;
derivedWatch.path = this.path;
derivedWatch.prototype = Watch.prototype;
return derivedWatch;
};
/**
* @param {string} target
*/
sync(target){
return Sync.create(this, this.host.resolve(this.path), this.host.from(target));
}
}
/**
*
* @param {string} path - Path to start watching
* @param {Host} host - Host to base from when doing operations like Watch.sync
* @returns {Watch}
*/
Watch.create = (path, host)=>{
/** @type {Array.<ZenObservable.Observer<WatchEvent>>} */
let observers = [];
let emitFn = (event)=>{
for(let observer of observers){
observer.next(event);
}
};
let watcher = new Watcher(path, emitFn);
let watch = new Watch(observer => {
observers.push(observer);
if (observers.length === 1) {
watcher.start();// Get this party started!!
}
return () => {
// On unsubscribe
observers.splice(observers.indexOf(observer), 1);
if (observers.length === 0) watcher.stop();
}
});
watch.host = host.from();// Forked host
watch.path = path;
return watch;
};
export {WatchEvent, Watcher};

360
src/helpers/watcher.js Normal file
View File

@ -0,0 +1,360 @@
/**
* @module watch
*/
import nodePath from "path";
import fs from "fs";
import * as fsp from "./fs-promises";
import {FsNode} from "./fs-node";
const IS_DEBUG = false;// Hard-code this to true to debug watch-related things
/**
* @typedef ChangedFile
* @property {'discovered','added','removed','modified'} event
* @property {string} file
*/
export class ChangedFile{
/**
* @param {ChangedFile} c
*/
constructor(c){
if(c){
this.event = c.event;
this.file = c.file;
this.path = c.path;
}
}
/** @type {'discovered','added','removed','modified'} */
event;
/** @type {string} */
file;
/** @type {string} */
path;
}
/**
* Describes an event emitted from watching a set of files
* @class
*/
export class WatchEvent{
/**
* @constructor
* @param {WatchEvent} c
*/
constructor(c){
if(c){
this.type = c.type;
this.changes = c.changes;
this.files = c.files;
this.nodes = c.nodes;
}
}
/**
* Type of watch-event
*
* @type {'dicovery'|'changes'}
*/
type;
/**
* @type {Array<ChangedFile>}
*/
changes;
/**
* @type {Array<string>}
*/
files;
/**
* @type {Array<FsNode>}
*/
nodes;
}
/**
* Internal structure to keep track of watched fs-nodes
*
* @class
*/
class WatchedNode extends FsNode{
/**
* @constructor
* @param {WatchedNode} c
*/
constructor(c){
super(c);
if(c){
this.parent = c.parent;
}
}
/**
* A watcher instance from NodeJS:fs
*
* @type {module:fs.FSWatcher}
*/
fsWatcher;
/**
* A map of child-items
*
* @type {Map.<string, WatchedNode>}
*/
children;
/**
* The parent node of this node
*
* @type {WatchedNode}
*/
parent;
}
/**
* Watch object representing some directory being watched
*
* @class
*/
export class Watcher{
/** @type {WatchedNode} */
#root;
/** Callback for emitting */
#emit = ()=>{};
/**
* Traverse the tree and list all nodes
* @returns {Array<FsNode>}
*/
#listNodes(){
let list = [];
let queue = [this.#root];
while (queue.length > 0) {
/** @type {WatchedNode} */
let item = queue.splice(0, 1)[ 0 ];
if (item.file) list.push(new FsNode(item));
if (item.children) {
queue.splice(0, 0, ...Array.from(item.children.values()).sort((a, b) => {
let azCompare = a.file < b.file ? -1 : 1;
let aIsDir = a.stats && a.stats.isDirectory();
let bIsDir = b.stats && b.stats.isDirectory();
return aIsDir && !bIsDir ? -1 : (bIsDir && !aIsDir ? 1 : azCompare);
}));
}
}
return list;
}
// Detection
#markedNodes = new Set();
#queuedRecheck = false;
#markRecheck(item){
IS_DEBUG && console.log("\n{{ RECHECK QUEUED " + item.file + " }}\n");
this.#markedNodes.add(item);
if (!this.#queuedRecheck) {
this.#queuedRecheck = setTimeout(() => {
IS_DEBUG && console.log("\n{{ RECHECKING }}\n");
this.#queuedRecheck = false;
this.#recheck(false);
}, 10);
}
}
async #recheck(isInitial){
// Initialize
let changes = new Map();
let checking = true;
let queue = Array.from(this.#markedNodes);
this.#markedNodes = new Set();
let rootPath = this.#root.path;
while (queue.length > 0) {
let newQueuedItems = await Promise.all(
queue.map(async (queuedItem) => {
/** @type {WatchedNode} **/
let watcher = queuedItem;
let watchFile = watcher.file;
let watchPath = watchFile ? nodePath.resolve(rootPath, watchFile) : rootPath;// might want to store this so we don't have to do this everytime
let removeQueue = [];
let newQueue = [];
if (changes.get(watcher) === 'removed') return; // SKIP, removal already processed
let prevStat = watcher.stats ? { size: watcher.stats.size, mtime: watcher.stats.mtimeMs } : null;
watcher.stats = await fsp.tryStat(watchPath);
let newStat = watcher.stats ? { size: watcher.stats.size, mtime: watcher.stats.mtimeMs } : null;
if (watcher.stats) {
// Node still exists
if (watcher.stats.isDirectory()) {
let dirContents = await fsp.readdir(watchPath);
let oldChildren = watcher.children || new Map();
let newChildren = new Map();
let changed = [];
for(let child of dirContents){
let file = nodePath.posix.join(...[watcher.file, child].filter(x => x));
let path = nodePath.resolve(...[rootPath, watcher.path, child].filter(x => x));
let childWatcher = oldChildren.get(file);
if(!childWatcher){
childWatcher = new WatchedNode({
path: path,
file: file,
parent: watcher
});
changes.set(childWatcher, 'added');
changed.push(childWatcher);
}
newChildren.set(file, childWatcher);
}
if (watcher.children) {
for (let [key, child] of watcher.children) {
if (!newChildren.has(key)) {
changes.set(child, 'removed');
changed.push(child);
removeQueue.push(child);
}
}
}
watcher.children = newChildren;
newQueue.splice(0, 0, ...changed);
if (changed.length) {
if (changes.get(watcher) !== 'added') changes.set(watcher, 'updated');
}
} else {
if (JSON.stringify(prevStat) !== JSON.stringify(newStat) && !changes.get(watcher)) {
if (!prevStat) changes.set(watcher, 'added');
else changes.set(watcher, 'updated');
}
}
// Start a watch if the path exists
if (!watcher.fsWatcher) {
watcher.fsWatcher = fs.watch(watchPath, {
// options
}, (eventType, fileName) => {
if (checking || !watcher.children) return;// Early exit, this happens when scanning a dir for files. A change is often triggered for directories accessed...
let changedFile = nodePath.posix.join(...[watcher.file, fileName].filter(x => x));
let changedPath = nodePath.resolve(...[rootPath, watcher.path, fileName].filter(x => x));
let child = watcher.children.get(changedFile);
IS_DEBUG && console.log("[EV] " + eventType.toUpperCase() + ": " + changedFile);
if (!child) {
child = new WatchedNode({
path: changedPath,
file: changedFile,
parent: watcher
});
watcher.children.set(changedFile, child);
}
this.#markRecheck(child);
}
);
IS_DEBUG && console.log("{{ STARTED WATCH ON " + watchFile + "}}");
}
} else {
// Node is removed!
removeQueue.push(watcher);
}
// Process anything removed in this tree
for (let item of removeQueue) {
let removednode = removeQueue.splice(0, 1)[ 0 ];
changes.set(removednode, 'removed');
if (removednode.parent && removednode.parent.children) {
IS_DEBUG && console.log(`{{ Removed ${removednode.file} from watch }}`);
removednode.parent.children.delete(removednode.file);
}
if (removednode.fsWatcher) {
IS_DEBUG && console.log("{{ STOPPED WATCH ON " + removednode.file + "}}");
removednode.fsWatcher.close();
delete removednode.fsWatcher;
}
if (removednode.children) {
removeQueue.splice(0, 0, ...Array.from(removednode.children.values));
}
}
return newQueue;
})
);
queue = [].concat(...newQueuedItems.filter(x => x));
}
checking = false;
let nodes = this.#listNodes();
let event = new WatchEvent({
type: isInitial? 'discovery': 'changed',
changes: [],
files: nodes.map(({ file }) => file),//nodes.filter(x=>!x.stats||!x.stats.isDirectory()).map(({file})=>file),
nodes: nodes
});
for (let [key, value] of changes) {
if (!isInitial || key.file) {
event.changes.push(new ChangedFile({
event: isInitial && value === 'added' ? 'discovered' : value,
file: key.file,
path: nodePath.resolve(rootPath, key.file)
}));
}
}
if (event.changes.length > 0 || isInitial) {
this.#emit(event);
} else {
if (!isInitial) {
IS_DEBUG && console.warn("{{ CHECKED WITHOUT SEEING CHANGES }}");
}
}
}
/**
*
* @param rootPath
*/
constructor(rootPath, emit){
/** @type {WatchedNode} */
this.#root = new WatchedNode({
file: '',
path: rootPath
});
this.#emit = emit;
}
// Methods
async start(){
IS_DEBUG && console.log("{{ STARTING WATCH }}");
this.#markedNodes.add(this.#root);
await this.#recheck(true);
}
async stop(){
IS_DEBUG && console.log("{{ STOPPING WATCH }}");
let queue = [this.#root];
while (queue.length > 0) {
let watcher = queue.splice(0, 1);
if (watcher.fsWatcher) {
watcher.fsWatcher.close();
delete watcher.fsWatcher;
}
if (watcher.children) {
queue.splice(0, 0, ...watcher.children);
delete watcher.children;
}
if (watcher.stats) {
delete watcher.stats;
}
}
}
}

674
src/host.js Normal file
View File

@ -0,0 +1,674 @@
// NodeJS
import nodePath from "path";
import { exec, spawn } from "child_process";
import process from "process";
// Libraries
import Observable from 'zen-observable';
import { AnsiUp } from "ansi-up";
import minimatch from "minimatch";
// Helpers
import * as fsp from "./helpers/fs-promises"
import { TraveralState, SkipNode, SkipChildren } from "./helpers/traverse";
import { Watch, WatchEvent } from "./helpers/watch";
import { FsNode } from "./helpers/fs-node";
import { TaskToken } from "./helpers/task-token";
/**
* Must haves
* - write (file with auto-create parent path, overwrite/append options)
* - watch (with glob or regex matching)
* - copy
* - clean/remove (either clean a dir, or remove with glob or regex matching)
*/
/**
* @typedef {TraveralState.traverseCallback | string} TraversalArg
*/
/**
* Host environment (keeps track of current working directory, promisifies fs-functions and provides host-utilities)
*/
export class Host {
/**
* @param {Host} obj - Host to clone from
* @param {string} obj.workingDirectory - Directory to work in
*/
constructor({
workingDirectory
} = {}) {
this.workingDirectory = workingDirectory;
this.ansiUp = new AnsiUp();
}
logToConsole = true;
/**
* Resolve a path
*
* @param {...string} path - Paths to resolve
* @return {string}
*/
resolve(...path) {
if (this.workingDirectory) return nodePath.resolve(this.workingDirectory, ...path);
else return nodePath.resolve(...path);
}
/** Get relative path name
* @param {...string} path - Paths to resolve
* @return {string}
*/
relative(...path){
let abs = this.resolve(...path);
let localRelative = nodePath.relative(this.workingDirectory, abs);
return localRelative.split(nodePath.sep).join(nodePath.posix.sep);
}
/**
* Like cwd this creates a new Host-node relative to the current one. Unlike cwd though, from does not support creating dirs
* and does not check existence before setting the workingDirectory. Because of this Host.from(...) is synchronous.
*
* @param {string} path - Relative path to switch working directory
* @return {Host}
*/
from(...path) {
return new Host({ workingDirectory: this.resolve(...path) })
}
/**
* Asynchronous stat(2) - Get file status.
*
* @param {...string} path - A path to a file.
* @return {Promise<module:fs.Stats>}
*/
async stat(...path) {
return await fsp.stat(this.resolve(...path));
}
/**
* Similar to stat except this will return null, when the file does not exist instead of throw an error
*
* @param {..string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<module:fs.Stats>}
*/
async tryStat(...path) {
return await fsp.tryStat(this.resolve(...path));
}
/**
* Create a new Host-node with it's working directory set to the specified path. If desired an option can be passed
* to make sure the relevant directories are created first
*
* @param {string} path - Relative path to switch working directory
* @param {Object} [opts] - Options to the command
* @param {boolean} [opts.create] - Create the directories if needed
* @return {Promise<Host>}
*/
async cwd(path, { create } = { create: true }) {
let resolved = path ? this.resolve(path) : this.workingDirectory;
try {
let dirStat = await this.tryStat(resolved);
if (!dirStat) {
if (!create) throw new Error(`Can't switch to ${path}: Directory ${resolved} does not exist`);
else {
await this.assertDir(resolved, typeof (create) === 'string' ? create : undefined);
}
} else if (!dirStat.isDirectory()) {
throw new Error(`Can't switch to ${path}: ${resolved} is not a directory`);
}
return new Host({ workingDirectory: resolved });
} catch (e) {
throw e;
}
}
/**
* Make sure the specified directory exists, creating it if needed.
*
* @param {string} [path]
* @param {string} [mode]
* @return {Promise<Host>}
*/
async assertDir(path, mode) {
let createQueue = [];
let resolved = this.resolve(path);
let next = resolved;
let stat = await this.tryStat(next);
while (!stat) {
createQueue.splice(0, 0, next);
next = nodePath.dirname(next);
stat = await this.tryStat(next);
}
while (stat) ;
for (let dir of createQueue) {
await this.mkdir(dir, mode).catch(err => {
if (err.code === 'EEXIST') return true;// Someone beat us to it
else throw err;// Still an error!
});
}
if (this.workingDirectory === resolved) {
return this;
} else {
return new Host({ workingDirectory: resolved });
}
}
/**
* Asynchronously writes data to a file, replacing the file if it already exists. The underlying file will _not_ be closed automatically.
* The `FileHandle` must have been opened for writing.
* It is unsafe to call `writeFile()` multiple times on the same file without waiting for the `Promise` to be resolved (or rejected).
* @param data The data to write. If something other than a `Buffer` or `Uint8Array` is provided, the value is coerced to a string.
* @param {Object} [options] - Either the encoding for the file, or an object optionally specifying the encoding, file mode, and flag.
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - If `encoding` is not supplied, the default of `'utf8'` is used.
* @param {string} [options.mode] - If `mode` is not supplied, the default of `0o666` is used.
* If `mode` is a string, it is parsed as an octal integer.
* @param {string} [options.flag] - If `flag` is not supplied, the default of `'w'` is used.
* @param {boolean} [options.create] - Create the path to the directory if needed
* @return {Promise<void>}
*/
async write(path, data, options) {
let targetPath = this.resolve(path);
let writeFileOpts = {};
if (options) {
for (let key in ['encoding', 'mode', 'flag']) {
if (options.hasOwnProperty(key)) writeFileOpts[ key ] = options[ key ];
}
}
let createPath = options && options.create !== undefined ? options.create : true;
try {
await this.writeFile(targetPath, data, writeFileOpts);
} catch (err) {
if (err.code === 'ENOENT' && createPath) {
let dirPath = this.resolve(nodePath.dirname(targetPath));
await this.mkdir(dirPath, { recursive: true });
await this.writeFile(targetPath, data, writeFileOpts);
} else {
throw err;
}
}
}
/**
* Read a file
*
* @param {string} path
* @param {Object} [options] - Read options, like encoding and mode
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - Encoding
* @param {string} [options.flag] - If a flag is not provided, it defaults to `'r'`.
* @return {Promise<Buffer>}
*/
async read(path, options) {
return this.readFile(path, options);// Zero special handling needed over the promisified fs-function
}
/**
* Remove a file or directory. If trying to delee a non-empty directory, the recursive option must be set, or it will throw an error
*
* @param {string} path
* @param {Object} [options] - Optional options
* @param {boolean} [options.recursive] - Indicates whether files in a directory should be deleted
* @return {Promise<boolean>}
*/
async remove(path, options) {
let { recursive } = options || {};
let resolved = this.resolve(path);
let stat = await this.tryStat(resolved);
if (!stat) {
return false; // There was nothing to delete
} else if (stat.isFile()) {
await this.unlink(resolved);
} else if (stat.isDirectory()) {
let contents = await this.readdir(resolved);
if (contents.length > 0) {
if (!recursive) throw new Error(`Can't remove non-empty directory ${path} (${resolved})`);
else {
await Promise.all(contents.map(f => this.remove(this.resolve(path, f), options)));
}
}
await this.rmdir(path);
} else {
throw new Error(`Can't remove ${path}: ${resolved} is an unexpected edge-case`);
}
return true;// Whatever needed deleting got deleted
}
/**
* Traverse directory at path and return the relative file
*
* @param {...TraversalArg} [filterOrPath]
* @return {Promise<Array.<string>>}
*/
async traverse(...filterOrPath) {
return (await this.traverseNodes(...filterOrPath)).map(node => node.file);
}
/**
* Traverse directory at path and return the full-nodes (see return-value). Optionally specify a filter callback
*
* @param {...TraversalArg} [filterOrPath]
* @return {Promise<Array.<TraveralState>>}
*/
async traverseNodes(...filterOrPath) {
let filters = [];
let paths = [];
for (let value of filterOrPath) {
if (value) {
if (typeof (value) === 'string') {
paths.push(value)
} else {
filters.push(value);
}
}
}
let nodes = [];
let resolved = this.resolve(...paths);
let queue = (await this.readdir(resolved));
while (queue.length > 0) {
let file = queue.splice(0, 1)[ 0 ];
let path = this.resolve(resolved, file);
let stats = await this.stat(path);
let state = new TraveralState(file, resolved, stats);
let result = filters.length === 0 ? true : undefined;
for (let filter of filters) {
let filterResult = await Promise.resolve(filter(file, state));
if (result === undefined || filterResult !== undefined) {
result = filterResult;
}
}
if (state.skipped) result = SkipNode;
else if (state.skippedChildren) result = SkipChildren;
else result = !!result;
if (result === true || result === SkipChildren) {
nodes.push(state);
}
if (result !== SkipNode && result !== SkipChildren) {
if (stats.isDirectory()) {
queue.splice(0, 0, ...(await this.readdir(path)).map(x => nodePath.posix.join(file, x)));
}
}
}
return nodes;
}
/**
* Match a set of files based on glob patterns. The files returned will be written using relative paths .e.g: src/**.*js will return src/main.js
*
* @param {string} pattern
* @return {Promise<Array.<string>>}
*/
async glob(pattern, options) {
// TODO this, properly...
return (await this.globNodes(pattern, options)).map(state => state.file);
}
/**
* Match a set of files based on glob patterns. The items returned are full traversal-states, thus including stats and an absolute path
*
* @param {string} pattern
* @return {Promise<Array.<TraveralState>>}
*/
async globNodes(pattern, options) {
let { path, pattern: mmPattern } = splitPattern(pattern);
let mm = mmPattern ? new minimatch.Minimatch(mmPattern, options) : null;
if (path) {
let derivedHost = this.from(path);
let nodes = await derivedHost.traverseNodes(mm ? (file, state) => mm.match(file) : null);
return nodes.map(node => Object.assign(node, {
file: nodePath.posix.join(...([path, node.file].filter(x => x))),
root: this.workingDirectory
}));
} else {
return await this.traverseNodes(mm ? (file, state) => mm.match(file) : null);
}
}
/**
* Watch a pattern or path for changes to its files
* // Suggestion rework this to watch(path) and then glob(pattern) as an observable transformer
*
* @param {string} path - Path to watch
* @return {Watch}
*/
watch(path) {
let resolvedPath = path ? this.resolve(path) : this.workingDirectory;
return Watch.create(resolvedPath, this);
}
/**
* Copy file(s) from one place to another (TODO: this method aint properly finished yet!)
*
* @param {string|string[]} from
* @param {string} to
* @return {Promise<void>}
*/
async copy(from, to) {
let resolvedTo = this.resolve(to);
if (!(from instanceof Array)) {
from = [from];
}
let sources = await Promise.all(
from.map(async (src) => {
if (!src || typeof (src) === 'string') {
let srcPath = this.resolve(src);
let stats = await this.tryStat(srcPath);
if (!stats) throw new Error(`File ${src} not found!`);
return {
file: src,
path: srcPath,
stats: stats
}
} else if (src && src.stats && src.path) {
return src;
}
})
);
let toStat = await this.tryStat(resolvedTo);
if (sources.length === 1 && sources[ 0 ].stats.isFile() && (!toStat || toStat.isFile())) {
// Simple copy file
let toDir = nodePath.dirname(resolvedTo);
await this.assertDir(toDir, { create: true });
await this.copyFile(sources[ 0 ].path, resolvedTo);
} else {
throw new Error("Currently unsupported!");
}
return;
}
// TODO: the idea is to make a sync(glob, targetDir) here, which would watch the glob pattern and sync files to the targetDir
// Promisified FS-functions (might want to make these as static?
// The naming in fs is inconsistent, should we unify them here?
/**
* Asynchronous mkdir(2) - create a directory.
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @param {Object} [options] - Either the file mode, or an object optionally specifying the file mode and whether parent folders
* should be created. If a string is passed, it is parsed as an octal integer. If not specified, defaults to `0o777`.
* @param {boolean} [options.recursive] - Indicates whether parent folders should be created.
* @param {string} [options.mode] - A file mode. If a string is passed, it is parsed as an octal integer. If not specified 0o777 is used as a default
* @return {Promise<void>}
*/
async mkdir(path, options) {
return await fsp.mkdir(path, options);
}
/**
* Asynchronous rmdir(2) - delete a directory.
* @param {...string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<void>}
*/
async rmdir(...path) {
return await fsp.rmdir(this.resolve(...path));
}
/**
* Asynchronous delete a name and possibly the file it refers to.
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @return {Promise<void>}
*/
async unlink(...path) {
return await fsp.unlink(this.resolve(...path));
}
/**
* Asynchronous readdir(3) - read a directory.
* @param {string} path - A path to a file. If a URL is provided, it must use the `file:` protocol.
* @param {Object} [options] - The encoding (or an object specifying the encoding), used as the encoding of the result. If not provided, `'utf8'` is used.
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - Encoding
* @param {boolean} [options.withFileTypes] - Include file types
* @return {Promise<Array.<string>>}
*/
async readdir(path, options) {
return await fsp.readdir(this.resolve(path), options);
}
/**
* Read a file
*
* @param {string} path
* @param {Object} [options] - Read options, like encoding and mode
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - Encoding
* @param {string} [options.flag] - If a flag is not provided, it defaults to `'r'`.
* @return {Promise<Buffer>}
*/
async readFile(path, options) {
return await fsp.readFile(this.resolve(path), options);
}
/**
* Asynchronously writes data to a file, replacing the file if it already exists. The underlying file will _not_ be closed automatically.
* The `FileHandle` must have been opened for writing.
* It is unsafe to call `writeFile()` multiple times on the same file without waiting for the `Promise` to be resolved (or rejected).
* @param {string} path - Path to write to
* @param {Buffer|string} data - The data to write. If something other than a `Buffer` or `Uint8Array` is provided, the value is coerced to a string.
* @param {Object} [options] - Either the encoding for the file, or an object optionally specifying the encoding, file mode, and flag.
* @param {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} [options.encoding] - If `encoding` is not supplied, the default of `'utf8'` is used.
* @param {string} [options.mode] - If `mode` is not supplied, the default of `0o666` is used.
* If `mode` is a string, it is parsed as an octal integer.
* @param {string} [options.flag] - If `flag` is not supplied, the default of `'w'` is used.
* @return {Promise<void>}
*/
async writeFile(path, data, options) {
return await fsp.writeFile(this.resolve(path), data, options);
}
/**
* Asynchronously copies src to dest. By default, dest is overwritten if it already exists.
* No arguments other than a possible exception are given to the callback function.
* Node.js makes no guarantees about the atomicity of the copy operation.
* If an error occurs after the destination file has been opened for writing, Node.js will attempt
* to remove the destination.
* @param {string} src A path to the source file.
* @param {string} dest A path to the destination file.
* @param {string} flags An integer that specifies the behavior of the copy operation. The only supported flag is fs.constants.COPYFILE_EXCL, which causes the copy operation to fail if dest already exists.
* @return {Promise<void>}
*/
async copyFile(path, dest, flags) {
let busyAttemptsRemain = 3;
while (busyAttemptsRemain) {
--busyAttemptsRemain;
let done = await fsp.copyFile(this.resolve(path), dest, flags)
.then(() => true)
.catch(err => {
if (err.code === 'EBUSY' && busyAttemptsRemain) {
return new Promise((resolve, reject) => setTimeout(() => resolve(false), 10));
} else {
throw err;
}
});
if (done) return;
// TODO want to know how much this happens (a retry because resource is busy...)
}
}
// TO BE UPDATED/UNDECIDED
/**
* @param message
* @param options
* @param task
*/
log(message, options, task) {
let messageObj = {
html: this.ansiUp.ansi_to_html(message).replace(/\n/gm, '<br/>').replace(/ /gm, '&nbsp;'),// Keep newlines and spaces preserved into html
text: this.ansiUp.ansi_to_text(message),
};
if (!messageObj.html.endsWith('<br/>')) {
messageObj.html += '<br/>';
}
let cleanedMessage = messageObj.text.trim();//cleanUpMessage(message);
if (cleanedMessage && this.logToConsole) {
if (options?.type === 'error') {
console.error(cleanedMessage);
} else if (options?.type === 'warning') {
console.warn(cleanedMessage);
} else {
console.log(cleanedMessage);
}
}
if (task) {
task.log(messageObj, options);
}
}
async run(commands, options, taskToken) {
let results = [];
for (let command of commands) {
if (command !== null && command !== undefined) {
results.push(
await this.spawn(command, options, taskToken)
);
} else {
// NO-OP (without this here it the function does not appear to do anything)
await new Promise((resolve, reject) => resolve());
}
}
return results;
}
// Old mapped from fs (but modified more heavily...)
exec(command, options = {}, taskToken) {
return new Promise((resolve, reject) => {
let cwd = options.workingDirectory ? path.resolve(options.workingDirectory) : this.workingDirectory;
let commandThread = exec(command, Object.assign({}, options || {}, { cwd: cwd }), (err, stdout, stderr) => err ? reject(err) : resolve({
out: stdout,
err: stderr
}));
commandThread?.stdout.on('data', (data) => {
this.log(data, { type: "info" }, taskToken);
});
commandThread?.stderr.on('data', (data) => {
this.log(data, { type: "error" }, taskToken);
});
});
}
spawn(command, options, taskToken) {
if (taskToken) taskToken.throwIfCancelled();
let spawnTask = new TaskToken(taskToken);
return new Promise((resolve, reject) => {
try {
this.log("> " + command, { type: "info" }, spawnTask);
let splitCommand = splitArgs(command);
let spawnCommand = splitCommand[ 0 ];
let cwd = options?.workingDirectory ? nodePath.resolve(options.workingDirectory) : this.workingDirectory;
if (spawnCommand === 'npm' && process.platform === 'win32') spawnCommand = 'npm.cmd';// Odd fix, but it does its job
else if (spawnCommand === 'yarn' && process.platform === 'win32') spawnCommand = 'yarn.cmd';// Odd fix, but it does its job
let commandThread = spawn(spawnCommand, splitCommand.slice(1), Object.assign({
encoding: 'utf8'
}, options || {}, { cwd: cwd })
);
let ended = false;
if (taskToken) taskToken.onCancelled.then(() => {
if (!ended) {
this.log("Cancelling command " + command, { type: "warning" }, spawnTask);
if (process.platform === 'win32') {
// kill wont work in windows
spawn("taskkill", ["/pid", commandThread.pid, '/f', '/t']);
} else {
commandThread.kill('SIGKILL');
}
}
});
commandThread.stdout.setEncoding('utf8');
commandThread.stdout.on('data', (data) => {
this.log(data, { type: "info" }, spawnTask);
});
commandThread.stderr.setEncoding('utf8');
commandThread.stderr.on('data', (data) => {
this.log(data, { type: "error" }, spawnTask);
});
commandThread?.on('error', (error) => {
reject(error);
});
commandThread?.on('close', (code) => {
ended = true;
spawnTask.complete();
try {
spawnTask.throwIfCancelled();
resolve({ code: code, log: spawnTask.taskLog });
} catch (err) {
reject(err);
}
});
} catch (err) {
reject(err);
}
});
}
}
// Helper functions
/**
* Splits a command into its seperate args (for fs.spawn i believe?)
* @param {string} command
* @returns {Array.<string>}
*/
function splitArgs(command) {
let reg = /([^'" ]+|(?:['"][^'"]+['"]))/g;
let match, lastIndex = 0;
let args = [];
do {
match = reg.exec(command);
if (match) {
args.push(match[ 0 ]);
lastIndex = match.index + match[ 0 ].length + 1;
} else {
lastIndex = command.length;
}
} while (lastIndex < command.length);
return args;
}
/**
* Splits a glob pattern into a fixed part, and a matcher part
* @param {string} pattern
* @returns {{path:string, pattern:string}}
*/
function splitPattern(pattern) {
if (!pattern) return { path: pattern };
let rawPatternParts = pattern.split('/');
let patternSymbols = new Set("*.?!+{}[]()|@".split(''));
for (let i = 0; i < rawPatternParts.length; ++i) {
let part = rawPatternParts[ i ];
if ((part !== '..' && part !== '.' && part.split('').find(c => patternSymbols.has(c)))) {
// stop this loop, we found the start of the pattern we we're looking for
return {
path: rawPatternParts.slice(0, i).join('/'),
pattern: rawPatternParts.slice(i).join('/')
};
}
}
return { path: pattern, pattern: '' }
}
// Add types as statics
Host.Node = FsNode;
Host.WatchEvent = WatchEvent;
// Export a default instance
export const host = new Host();
export default host;

6
src/index.js Normal file
View File

@ -0,0 +1,6 @@
import {Host, host} from "./host";
export {TaskToken} from "./helpers/task-token"; // Needs updating, this comes from the cerxes-alpha-node runner, and is intended for cancaling yarn/npm install jobs
export {Host, host};
export {processArgs} from "./process-args";
export default host;// Is this necessary?

11
src/process-args.js Normal file
View File

@ -0,0 +1,11 @@
const args = process.argv;
// TODO this whole file
export const processArgs = {
cmd: args[0],
args: args.slice(1),
// TODO: Flags
// TODO: Options/Variables/whatever-you-call-it
// TODO: env-vars?
};

21
tests/.babelrc Normal file
View File

@ -0,0 +1,21 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
],
"plugins": [
[ "@babel/plugin-proposal-decorators" , { "legacy": true }],
[ "@babel/plugin-proposal-class-properties", { "loose": true } ],
[ "@babel/plugin-proposal-private-methods", {"loose": true } ],
[ "@babel/plugin-proposal-optional-chaining" ],
[ "@babel/plugin-proposal-nullish-coalescing-operator" ],
[ "@babel/plugin-proposal-export-namespace-from" ],
[ "@babel/plugin-proposal-export-default-from" ]
]
}

View File

@ -0,0 +1 @@
I serve to fill a tree of files

View File

@ -0,0 +1 @@
I also serve to fill a tree of files!

1
tests/host/basic/dist/test-file.txt vendored Normal file
View File

@ -0,0 +1 @@
I am a very basic test-file yo!

View File

@ -0,0 +1 @@
I am the first basic test-file!

View File

@ -0,0 +1,33 @@
import {Host} from "../../dist";
import {resolve} from "path";
test('host.basic', async ()=>{
let host = new Host({workingDirectory: __dirname});
await host.remove("basic", {recursive: true});// Make sure dir is removed and we have clean start (could have the user pass a force flag here..
let testHost = await host.cwd('basic');
await testHost.write('test-file.out', "I am the first basic test-file!");
await testHost.write('dist/test-file.txt', "I am a very basic test-file yo!");
await testHost.write('dist/other/another-file.txt', "I serve to fill a tree of files");
await testHost.write('dist/other/more-files.out', "I also serve to fill a tree of files!");
let tree = await testHost.traverse();
expect(tree.length).toBe(6); // Expecting 6 results, because it should include the directories as well
let txtFiles = await testHost.glob('**/*.txt');
expect(txtFiles.length).toBe(2);
let outFiles = await testHost.glob('**/*.out');
expect(outFiles.length).toBe(2);
let subHost = testHost.from('dist/other');
let subOutFiles = await subHost.glob('../../**/*.out');
expect(subOutFiles.length).toBe(2);
/** @type {module:fs.Stats} */
let stats = await host.stat('basic/test-file.out');
expect(stats.isFile()).toBe(true);
});

View File

@ -0,0 +1,12 @@
import {Host} from "../../dist";
test('host.basic', async ()=>{
let host = new Host({workingDirectory: __dirname});
let distHost = host.from("dist");
let relMain = distHost.relative(host.resolve("dist/main.js"));
expect(relMain).toBe('main.js');
let relAsset = distHost.relative(host.resolve("dist/assets/icon.svg"));
expect(relAsset).toBe('assets/icon.svg');
});

View File

@ -0,0 +1 @@
I serve to fill a tree of files

View File

@ -0,0 +1 @@
I also serve to fill a tree of files!

1
tests/host/test/dist/test-file.txt vendored Normal file
View File

@ -0,0 +1 @@
I am a very basic test-file yo!

View File

@ -0,0 +1 @@
I am the first basic test-file!

1
tests/manual/dist/assets/icon2.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg>I'm an new example icon</svg>

After

Width:  |  Height:  |  Size: 34 B

1
tests/manual/dist/assets/logo.png vendored Normal file
View File

@ -0,0 +1 @@
I'm to lazy to make a miniature example image'

1
tests/manual/dist/index.html vendored Normal file
View File

@ -0,0 +1 @@
<html><body>I am no longer the same entry-html file!</body></html>

View File

@ -0,0 +1,5 @@
import {processArgs} from "../../dist";
let rawArgs = process.argv;
console.log(`Raw: ${JSON.stringify(rawArgs)}`);
console.log(`Processed: ${JSON.stringify(processArgs)}`);

View File

@ -0,0 +1,16 @@
import {host, Host} from "../../dist";
/** @type {Host} **/// Would really wish we can eliminate the need for this...
const local = host.from(__dirname);
let dummyScript = `process-args-dummy`;
let testCommands = [
`-xfv arg1 var1="some option value"`
];
for(let testCmd of testCommands){
let cmd = `node -r @babel/register ${dummyScript} ${testCmd}`;
console.log(`Test: ${cmd}`);
local.exec(cmd);
}

View File

@ -0,0 +1 @@
//I represent another source-file whose edit shouldn't have been seen!

View File

@ -0,0 +1 @@
<svg>I'm an new example icon</svg>

After

Width:  |  Height:  |  Size: 34 B

View File

@ -0,0 +1 @@
I'm to lazy to make a miniature example image'

View File

@ -0,0 +1 @@
//I represent a service or common part of sorts

View File

@ -0,0 +1 @@
<html><body>I am no longer the same entry-html file!</body></html>

1
tests/manual/src/main.js Normal file
View File

@ -0,0 +1 @@
//I represent a main-file

View File

@ -0,0 +1 @@
I am no longer the same file i used to be!

View File

@ -0,0 +1 @@
I also serve to fill a tree of files!

1
tests/manual/test/dist/test-file2.txt vendored Normal file
View File

@ -0,0 +1 @@
I am a new test-file!

View File

@ -0,0 +1 @@
I am a new test-file!

View File

@ -0,0 +1 @@
I am the first basic test-file!

View File

@ -0,0 +1,62 @@
import {Host} from "../../dist";
async function testWatch(){
let host = new Host({workingDirectory: __dirname});
await host.remove("test", {recursive: true});// Make sure dir is removed and we have clean start (could have the user pass a force flag here..
let initialized = false;
let testHost = await host.cwd('test');
await testHost.write('test-file.out', "I am the first basic test-file!");
await testHost.write('dist/test-file.txt', "I am a very basic test-file yo!");
await testHost.write('dist/other/another-file.txt', "I serve to fill a tree of files");
await testHost.write('dist/other/more-files.out', "I also serve to fill a tree of files!");
initialized = true;
// TODO starting a watch on a dir before it is created does not work yet!!
let watchSub = host.watch("test").glob("**/*.txt").subscribe(({changes, files})=>{
console.log(`----------\nChanges:\n${
changes.map(x=>x.event.toUpperCase() + ": " + x.file).join('\n')
}\nFiles:\n${files.join("\n")}\n----------`);
});
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch a remove having occurred
console.log("\n** Removing dist/test-file.txt");
await testHost.remove('dist/test-file.txt');
console.log("** Removed dist/test-file.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch an add having occurred
console.log("\n** Adding dist/test-file2.txt");
await testHost.write('dist/test-file2.txt', 'I am a new test-file!');
console.log("** Added dist/test-file2.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch a copy having occurred
console.log("\n** Copying dist/test-file2.txt to dist2/test-file.txt");
await testHost.copy('dist/test-file2.txt', "dist2/test-file.txt");
console.log("** Copied dist2/test-file.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 1000));// clearly our current process is slow
// Watch an edit having occurred
console.log("\n** Writing dist/other/another-file.txt");
await testHost.write('dist/other/another-file.txt', 'I am no longer the same file i used to be!');
console.log("** Written dist/other/another-file.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 3000));// clearly our current process is slow
console.log("UNSUBSCRIBING!");
// No longer watching should close all file handles!
watchSub.unsubscribe();
console.log("DONE!");
}
testWatch();

View File

@ -0,0 +1,74 @@
import {Host} from "../../dist";
async function testWatch(){
let host = new Host({workingDirectory: __dirname});
let initialized = false;
// Initialize
await host.remove("src", {recursive: true});// Make sure dir is removed and we have clean start (could have the user pass a force flag here..
await host.remove("dist", {recursive: true});// Make sure dir is removed and we have clean start (could have the user pass a force flag here..
let srcHost = await host.cwd('src');
await srcHost.write('main.js', "//I represent a main-file");
await srcHost.write('assets/icon.svg', "<svg>I'm an example icon</svg>");
await srcHost.write('assets/logo.png', "I'm to lazy to make a miniature example image'");
await srcHost.write('common/another-source-file.js', "//I represent a service or common part of sorts");
await srcHost.write('.babelrc', "//I represent another source-file to be excluded from being copied");
await srcHost.write('index.html', "<html><body>I represent the entry-html file!</body></html>");
initialized = true;
console.log("Initialized, starting sync");
// Syntax like this for the moment! (this might change in the future...)
let syncSub = host.watch("src").glob("**/*.!(js|babelrc|json|scss)").sync("dist").subscribe(sync=>{
let changes = sync.changes;
console.log(`----------\nSYNCED Changes:\n${
changes.map(x=>x.event.toUpperCase() + ": " + x.file).join('\n')
}\n`);
});
let watchSub = host.watch("src").glob("**/*.!(js|babelrc|json|scss)").subscribe((event)=>{
let {changes, files} = event;
console.log(`----------\nOBSERVED Changes:\n${
changes.map(x=>x.event.toUpperCase() + ": " + x.file).join('\n')
}\nFiles:\n${files.join("\n")}\n----------`);
});
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch a remove having occurred
console.log("** Removing assets/icon.svg");
await srcHost.remove('assets/icon.svg');
console.log("** Removed assets/icon.svg\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch an add having occurred
console.log("** Adding assets/icon2.svg");
await srcHost.write('assets/icon2.svg', "<svg>I'm an new example icon</svg>");
console.log("** Added assets/icon2.svg\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));
// Watch an edit having occurred
console.log("** Writing index.html");
await srcHost.write('index.html', "<html><body>I am no longer the same entry-html file!</body></html>");
console.log("** Written index.html\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 1000));// clearly our current process is slow
// Test an unwatched edit having occurred
console.log("** Writing .babelrc");
await srcHost.write('.babelrc', "//I represent another source-file whose edit shouldn't have been seen!");
console.log("** Written babelrc\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 3000));// clearly our current process is slow
console.log("UNSUBSCRIBING!");
// No longer watching should close all file handles!
watchSub.unsubscribe();
syncSub.unsubscribe();
console.log("DONE!");
}
testWatch();

58
tests/manual/watch.dev.js Normal file
View File

@ -0,0 +1,58 @@
import {Host} from "../../dist";
async function testWatch(){
let host = new Host({workingDirectory: __dirname});
await host.remove("test", {recursive: true});// Make sure dir is removed and we have clean start (could have the user pass a force flag here..
let initialized = false;
let testHost = await host.cwd('test');
await testHost.write('test-file.out', "I am the first basic test-file!");
await testHost.write('dist/test-file.txt', "I am a very basic test-file yo!");
await testHost.write('dist/other/another-file.txt', "I serve to fill a tree of files");
await testHost.write('dist/other/more-files.out', "I also serve to fill a tree of files!");
initialized = true;
// TODO starting a watch on a dir before it is created does not work yet!!
let watchSub = host.watch("test").subscribe(({changes, files})=>{
console.log(`----------\nChanges:\n${
changes.map(x=>x.event.toUpperCase() + ": " + x.file).join('\n')
}\nFiles:\n${files.join("\n")}\n----------`);
});
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));// clearly our current process is slow
// Watch a remove having occurred
console.log("** Removing dist/test-file.txt");
await testHost.remove('dist/test-file.txt');
console.log("** Removed dist/test-file.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));// clearly our current process is slow
// Watch an add having occurred
console.log("** Adding dist/test-file2.txt");
await testHost.write('dist/test-file2.txt', 'I am a new test-file!');
console.log("** Added dist/test-file2.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(),1000));// clearly our current process is slow
// Watch a move having occurred TODO we don't have this feature yet!
//await testHost.move('dist/test-file2.txt', 'dist2/test-file.txt');
// Watch an edit having occurred
console.log("** Writing dist/other/another-file.txt");
await testHost.write('dist/other/another-file.txt', 'I am no longer the same file i used to be!');
console.log("** Written dist/other/another-file.txt\n");
await new Promise((resolve,reject)=>setTimeout(()=>resolve(), 3000));// clearly our current process is slow
console.log("UNSUBSCRIBING!");
// No longer watching should close all file handles!
watchSub.unsubscribe();
console.log("DONE!");
}
testWatch();

4763
yarn.lock Normal file

File diff suppressed because it is too large Load Diff