swarm-js
Version:
Swarm tools for JavaScript.
506 lines (452 loc) • 18.1 kB
JavaScript
// TODO: this is a temporary fix to hide those libraries from the browser. A
// slightly better long-term solution would be to split this file into two,
// separating the functions that are used on Node.js from the functions that
// are used only on the browser.
module.exports = ({
fs,
files,
os,
path,
child_process,
mimetype,
defaultArchives,
request,
downloadUrl,
bytes,
hash,
pick
}) => {
// ∀ a . String -> JSON -> Map String a -o Map String a
// Inserts a key/val pair in an object impurely.
const impureInsert = key => val => map =>
(map[key] = val, map);
// String -> JSON -> Map String JSON
// Merges an array of keys and an array of vals into an object.
const toMap = keys => vals => {
let map = {};
for (let i = 0, l = keys.length; i < l; ++i)
map[keys[i]] = vals[i];
return map;
};
// ∀ a . Map String a -> Map String a -> Map String a
// Merges two maps into one.
const merge = a => b => {
let map = {};
for (let key in a)
map[key] = a[key];
for (let key in b)
map[key] = b[key];
return map;
};
// ∀ a . [a] -> [a] -> Bool
const equals = a => b => {
if (a.length !== b.length) {
return false;
} else {
for (let i = 0, l = a.length; i < l; ++i) {
if (a[i] !== b[i]) return false;
}
}
return true;
}
// String -> String -> String
const rawUrl = swarmUrl => hash =>
`${swarmUrl}/bzz-raw:/${hash}`
// String -> String -> Promise Uint8Array
// Gets the raw contents of a Swarm hash address.
const downloadData = swarmUrl => hash =>
new Promise((resolve, reject) => {
request(rawUrl(swarmUrl)(hash), {responseType: "arraybuffer"}, (err, arrayBuffer, response) => {
if (err) {
return reject(err);
}
if (response.statusCode >= 400) {
return reject(new Error(`Error ${response.statusCode}.`));
}
return resolve(new Uint8Array(arrayBuffer));
})
});
// type Entry = {"type": String, "hash": String}
// type File = {"type": String, "data": Uint8Array}
// String -> String -> Promise (Map String Entry)
// Solves the manifest of a Swarm address recursively.
// Returns a map from full paths to entries.
const downloadEntries = swarmUrl => hash => {
const search = hash => path => routes => {
// Formats an entry to the Swarm.js type.
const format = entry => ({
type: entry.contentType,
hash: entry.hash});
// To download a single entry:
// if type is bzz-manifest, go deeper
// if not, add it to the routing table
const downloadEntry = entry => {
if (entry.path === undefined) {
return Promise.resolve();
} else {
return entry.contentType === "application/bzz-manifest+json"
? search (entry.hash) (path + entry.path) (routes)
: Promise.resolve (impureInsert (path + entry.path) (format(entry)) (routes));
}
}
// Downloads the initial manifest and then each entry.
return downloadData(swarmUrl)(hash)
.then(text => JSON.parse(toString(text)).entries)
.then(entries => Promise.all(entries.map(downloadEntry)))
.then(() => routes);
}
return search (hash) ("") ({});
}
// String -> String -> Promise (Map String String)
// Same as `downloadEntries`, but returns only hashes (no types).
const downloadRoutes = swarmUrl => hash =>
downloadEntries(swarmUrl)(hash)
.then(entries => toMap
(Object.keys(entries))
(Object.keys(entries).map(route => entries[route].hash)));
// String -> String -> Promise (Map String File)
// Gets the entire directory tree in a Swarm address.
// Returns a promise mapping paths to file contents.
const downloadDirectory = swarmUrl => hash =>
downloadEntries (swarmUrl) (hash)
.then(entries => {
const paths = Object.keys(entries);
const hashs = paths.map(path => entries[path].hash);
const types = paths.map(path => entries[path].type);
const datas = hashs.map(downloadData(swarmUrl));
const files = datas => datas.map((data, i) => ({type: types[i], data: data}));
return Promise.all(datas).then(datas => toMap(paths)(files(datas)));
});
// String -> String -> String -> Promise String
// Gets the raw contents of a Swarm hash address.
// Returns a promise with the downloaded file path.
const downloadDataToDisk = swarmUrl => hash => filePath =>
files.download (rawUrl(swarmUrl)(hash)) (filePath);
// String -> String -> String -> Promise (Map String String)
// Gets the entire directory tree in a Swarm address.
// Returns a promise mapping paths to file contents.
const downloadDirectoryToDisk = swarmUrl => hash => dirPath =>
downloadRoutes (swarmUrl) (hash)
.then(routingTable => {
let downloads = [];
for (let route in routingTable) {
if (route.length > 0) {
const filePath = path.join(dirPath, route);
downloads.push(downloadDataToDisk(swarmUrl)(routingTable[route])(filePath));
};
};
return Promise.all(downloads).then(() => dirPath);
});
// String -> Uint8Array -> Promise String
// Uploads raw data to Swarm.
// Returns a promise with the uploaded hash.
const uploadData = swarmUrl => data =>
new Promise((resolve, reject) => {
const params = {
body: typeof data === "string" ? fromString(data) : data,
method: "POST"
};
request(`${swarmUrl}/bzz-raw:/`, params, (err, data) => {
if (err) {
return reject(err);
}
return resolve(data);
});
});
// String -> String -> String -> File -> Promise String
// Uploads a file to the Swarm manifest at a given hash, under a specific
// route. Returns a promise containing the uploaded hash.
// FIXME: for some reasons Swarm-Gateways is sometimes returning
// error 404 (bad request), so we retry up to 3 times. Why?
const uploadToManifest = swarmUrl => hash => route => file => {
const attempt = n => {
const slashRoute = route[0] === "/" ? route : "/" + route;
const url = `${swarmUrl}/bzz:/${hash}${slashRoute}`;
const opt = {
method: "PUT",
headers: {"Content-Type": file.type},
body: file.data};
return new Promise((resolve, reject) => {
request(url, opt, (err, data) => {
if (err) {
return reject(err);
}
if (data.indexOf("error") !== -1) {
return reject(data);
}
return resolve(data);
});
}).catch(e => n > 0 && attempt(n-1));
};
return attempt(3);
};
// String -> {type: String, data: Uint8Array} -> Promise String
const uploadFile = swarmUrl => file =>
uploadDirectory(swarmUrl)({"": file});
// String -> String -> Promise String
const uploadFileFromDisk = swarmUrl => filePath =>
fs.readFile(filePath)
.then(data => uploadFile(swarmUrl)({type: mimetype.lookup(filePath), data: data}));
// String -> Map String File -> Promise String
// Uploads a directory to Swarm. The directory is
// represented as a map of routes and files.
// A default path is encoded by having a "" route.
const uploadDirectory = swarmUrl => directory =>
uploadData(swarmUrl)("{}")
.then(hash => {
const uploadRoute = route => hash => uploadToManifest(swarmUrl)(hash)(route)(directory[route]);
const uploadToHash = (hash, route) => hash.then(uploadRoute(route));
return Object.keys(directory).reduce(uploadToHash, Promise.resolve(hash));
});
// String -> Promise String
const uploadDataFromDisk = swarmUrl => filePath =>
fs.readFile(filePath)
.then(uploadData(swarmUrl));
// String -> Nullable String -> String -> Promise String
const uploadDirectoryFromDisk = swarmUrl => defaultPath => dirPath =>
files.directoryTree(dirPath)
.then(fullPaths => Promise.all(fullPaths.map(path => fs.readFile(path))).then(datas => {
const paths = fullPaths.map(path => path.slice(dirPath.length));
const types = fullPaths.map(path => mimetype.lookup(path) || "text/plain");
return toMap (paths) (datas.map((data, i) => ({type: types[i], data: data})));
}))
.then(directory => merge (defaultPath ? {"": directory[defaultPath]} : {}) (directory))
.then(uploadDirectory(swarmUrl));
// String -> UploadInfo -> Promise String
// Simplified multi-type upload which calls the correct
// one based on the type of the argument given.
const upload = swarmUrl => arg => {
// Upload raw data from browser
if (arg.pick === "data") {
return pick.data().then(uploadData(swarmUrl));
// Upload a file from browser
} else if (arg.pick === "file") {
return pick.file().then(uploadFile(swarmUrl));
// Upload a directory from browser
} else if (arg.pick === "directory") {
return pick.directory().then(uploadDirectory(swarmUrl));
// Upload directory/file from disk
} else if (arg.path) {
switch (arg.kind) {
case "data": return uploadDataFromDisk(swarmUrl)(arg.path);
case "file": return uploadFileFromDisk(swarmUrl)(arg.path);
case "directory": return uploadDirectoryFromDisk(swarmUrl)(arg.defaultFile)(arg.path);
};
// Upload UTF-8 string or raw data (buffer)
} else if (arg.length || typeof arg === "string") {
return uploadData(swarmUrl)(arg);
// Upload directory with JSON
} else if (arg instanceof Object) {
return uploadDirectory(swarmUrl)(arg);
}
return Promise.reject(new Error("Bad arguments"));
}
// String -> String -> Nullable String -> Promise (String | Uint8Array | Map String Uint8Array)
// Simplified multi-type download which calls the correct function based on
// the type of the argument given, and on whether the Swwarm address has a
// directory or a file.
const download = swarmUrl => hash => path =>
isDirectory(swarmUrl)(hash).then(isDir => {
if (isDir) {
return path
? downloadDirectoryToDisk(swarmUrl)(hash)(path)
: downloadDirectory(swarmUrl)(hash);
} else {
return path
? downloadDataToDisk(swarmUrl)(hash)(path)
: downloadData(swarmUrl)(hash);
}
});
// String -> Promise String
// Downloads the Swarm binaries into a path. Returns a promise that only
// resolves when the exact Swarm file is there, and verified to be correct.
// If it was already there to begin with, skips the download.
const downloadBinary = (path, archives) => {
const system = os.platform().replace("win32","windows") + "-" + (os.arch() === "x64" ? "amd64" : "386");
const archive = (archives || defaultArchives)[system];
const archiveUrl = downloadUrl + archive.archive + ".tar.gz";
const archiveMD5 = archive.archiveMD5;
const binaryMD5 = archive.binaryMD5;
return files.safeDownloadArchived(archiveUrl)(archiveMD5)(binaryMD5)(path);
};
// type SwarmSetup = {
// account : String,
// password : String,
// dataDir : String,
// binPath : String,
// ensApi : String,
// onDownloadProgress : Number ~> (),
// archives : [{
// archive: String,
// binaryMD5: String,
// archiveMD5: String
// }]
// }
// SwarmSetup ~> Promise Process
// Starts the Swarm process.
const startProcess = swarmSetup => new Promise((resolve, reject) => {
const {spawn} = child_process;
const hasString = str => buffer => ('' + buffer).indexOf(str) !== -1;
const {account, password, dataDir, ensApi, privateKey} = swarmSetup;
const STARTUP_TIMEOUT_SECS = 3;
const WAITING_PASSWORD = 0;
const STARTING = 1;
const LISTENING = 2;
const PASSWORD_PROMPT_HOOK = "Passphrase";
const LISTENING_HOOK = "Swarm http proxy started";
let state = WAITING_PASSWORD;
const swarmProcess = spawn(swarmSetup.binPath, [
'--bzzaccount', account || privateKey,
'--datadir', dataDir,
'--ens-api', ensApi]);
const handleProcessOutput = data => {
if (state === WAITING_PASSWORD && hasString (PASSWORD_PROMPT_HOOK) (data)) {
setTimeout(() => {
state = STARTING;
swarmProcess.stdin.write(password + '\n');
}, 500);
} else if (hasString (LISTENING_HOOK) (data)) {
state = LISTENING;
clearTimeout(timeout);
resolve(swarmProcess);
}
}
swarmProcess.stdout.on('data', handleProcessOutput);
swarmProcess.stderr.on('data', handleProcessOutput);
//swarmProcess.on('close', () => setTimeout(restart, 2000));
let restart = () => startProcess(swarmSetup).then(resolve).catch(reject);
let error = () => reject(new Error("Couldn't start swarm process."));
let timeout = setTimeout(error, 20000);
});
// Process ~> Promise ()
// Stops the Swarm process.
const stopProcess = process => new Promise((resolve, reject) => {
process.stderr.removeAllListeners('data');
process.stdout.removeAllListeners('data');
process.stdin.removeAllListeners('error');
process.removeAllListeners('error');
process.removeAllListeners('exit');
process.kill('SIGINT');
const killTimeout = setTimeout(
() => process.kill('SIGKILL'),
8000);
process.once('close', () => {
clearTimeout(killTimeout);
resolve();
});
});
// SwarmSetup -> (SwarmAPI -> Promise ()) -> Promise ()
// Receives a Swarm configuration object and a callback function. It then
// checks if a local Swarm node is running. If no local Swarm is found, it
// downloads the Swarm binaries to the dataDir (if not there), checksums,
// starts the Swarm process and calls the callback function with an API
// object using the local node. That callback must return a promise which
// will resolve when it is done using the API, so that this function can
// close the Swarm process properly. Returns a promise that resolves when the
// user is done with the API and the Swarm process is closed.
// TODO: check if Swarm process is already running (improve `isAvailable`)
const local = swarmSetup => useAPI =>
isAvailable("http://localhost:8500").then(isAvailable =>
isAvailable
? useAPI(at("http://localhost:8500")).then(() => {})
: downloadBinary(swarmSetup.binPath, swarmSetup.archives)
.onData(data => (swarmSetup.onProgress || (() => {}))(data.length))
.then(() => startProcess(swarmSetup))
.then(process => useAPI(at("http://localhost:8500")).then(() => process))
.then(stopProcess));
// String ~> Promise Bool
// Returns true if Swarm is available on `url`.
// Perfoms a test upload to determine that.
// TODO: improve this?
const isAvailable = swarmUrl => {
const testFile = "test";
const testHash = "c9a99c7d326dcc6316f32fe2625b311f6dc49a175e6877681ded93137d3569e7";
return uploadData(swarmUrl)(testFile)
.then(hash => hash === testHash)
.catch(() => false);
};
// String -> String ~> Promise Bool
// Returns a Promise which is true if that Swarm address is a directory.
// Determines that by checking that it (i) is a JSON, (ii) has a .entries.
// TODO: improve this?
const isDirectory = swarmUrl => hash =>
downloadData(swarmUrl)(hash)
.then(data => {
try {
return !!JSON.parse(toString(data)).entries;
} catch (e) {
return false;
}
});
// Uncurries a function; used to allow the f(x,y,z) style on exports.
const uncurry = f => (a,b,c,d,e) => {
var p;
// Hardcoded because efficiency (`arguments` is very slow).
if (typeof a !== "undefined") p = f(a);
if (typeof b !== "undefined") p = f(b);
if (typeof c !== "undefined") p = f(c);
if (typeof d !== "undefined") p = f(d);
if (typeof e !== "undefined") p = f(e);
return p;
};
// () -> Promise Bool
// Not sure how to mock Swarm to test it properly. Ideas?
const test = () => Promise.resolve(true);
// Uint8Array -> String
const toString = uint8Array =>
bytes.toString(bytes.fromUint8Array(uint8Array));
// String -> Uint8Array
const fromString = string =>
bytes.toUint8Array(bytes.fromString(string));
// String -> SwarmAPI
// Fixes the `swarmUrl`, returning an API where you don't have to pass it.
const at = swarmUrl => ({
download: (hash,path) => download(swarmUrl)(hash)(path),
downloadData: uncurry(downloadData(swarmUrl)),
downloadDataToDisk: uncurry(downloadDataToDisk(swarmUrl)),
downloadDirectory: uncurry(downloadDirectory(swarmUrl)),
downloadDirectoryToDisk: uncurry(downloadDirectoryToDisk(swarmUrl)),
downloadEntries: uncurry(downloadEntries(swarmUrl)),
downloadRoutes: uncurry(downloadRoutes(swarmUrl)),
isAvailable: () => isAvailable(swarmUrl),
upload: (arg) => upload(swarmUrl)(arg),
uploadData: uncurry(uploadData(swarmUrl)),
uploadFile: uncurry(uploadFile(swarmUrl)),
uploadFileFromDisk: uncurry(uploadFile(swarmUrl)),
uploadDataFromDisk: uncurry(uploadDataFromDisk(swarmUrl)),
uploadDirectory: uncurry(uploadDirectory(swarmUrl)),
uploadDirectoryFromDisk: uncurry(uploadDirectoryFromDisk(swarmUrl)),
uploadToManifest: uncurry(uploadToManifest(swarmUrl)),
pick: pick,
hash: hash,
fromString: fromString,
toString: toString
});
return {
at,
local,
download,
downloadBinary,
downloadData,
downloadDataToDisk,
downloadDirectory,
downloadDirectoryToDisk,
downloadEntries,
downloadRoutes,
isAvailable,
startProcess,
stopProcess,
upload,
uploadData,
uploadDataFromDisk,
uploadFile,
uploadFileFromDisk,
uploadDirectory,
uploadDirectoryFromDisk,
uploadToManifest,
pick,
hash,
fromString,
toString
};
};