edge-sync-client
Version:
Library for accessing the Edge data-sync system
372 lines (269 loc) • 9.48 kB
JavaScript
import { asObject, asOptional, asArray, asString, asNumber, asValue, asEither, asNull, asUndefined, uncleaner, asMaybe } from 'cleaners';
import crossFetch from 'cross-fetch';
import baseX from 'base-x';
import hashjs from 'hash.js';
import { base16 } from 'rfc4648';
/**
* Interprets a path as a series of folder lookups,
* handling special components like `.` and `..`.
*/
function normalizePath(path) {
if (/^\//.test(path)) throw new Error('Absolute paths are not supported')
const parts = path.split('/');
// Shift down good elements, dropping bad ones:
let i = 0; // Read index
let j = 0; // Write index
while (i < parts.length) {
const part = parts[i++];
if (part === '..') j--;
else if (part !== '.' && part !== '') parts[j++] = part;
if (j < 0) throw new Error('Path would escape folder')
// If path is something like `dir/..` or `dir/dir/../..`
if (j === 0 && i === parts.length)
throw new Error('Path would evaluate to empty file name')
}
// Array items from 0 to j are the path:
return parts.slice(0, j).join('/')
}
// Regexes:
const VALID_PATH_REGEX = /^[a-z0-9-_.]+(\/+[a-z0-9-_.]+)*$/i;
const VALID_SYNC_KEY_REGEX = /^[a-f0-9]{40}$/;
const asEdgeServers = asObject({
infoServers: asOptional(asArray(asString)),
syncServers: asOptional(asArray(asString))
});
//
// Primitive Types
//
const asNonEmptyString = (raw) => {
const str = asString(raw);
if (str === '') {
throw new TypeError('Expected non empty string')
}
return str
};
const asPath = (raw) => {
const path = asString(raw);
try {
if (VALID_PATH_REGEX.test(path)) return normalizePath(path)
} catch (_) {}
throw new Error(`Invalid path '${path}'`)
};
const asSyncKey = (raw) => {
const syncKey = asString(raw);
if (!VALID_SYNC_KEY_REGEX.test(syncKey)) {
throw new TypeError(`Invalid sync key '${syncKey}'`)
}
return syncKey
};
const asEdgeBox = asObject({
iv_hex: asString,
encryptionType: asNumber,
data_base64: asString
});
/**
* @file The types in this file describe the sync-server's REST protocol.
*
* The type names use the following suffixes:
* - Body: The JSON request body sent with the request.
* - Params: The query params passed in the URI.
* - Response: The JSON response body the endpoint returns.
*/
const asServerErrorResponse = asObject({
success: asValue(false),
message: asString,
stack: asOptional(asString)
});
const asFileChange = asEither(asEdgeBox, asNull);
const asChangeSet = asObject(asFileChange);
// GET /v2/store
const asGetStoreParams = asObject({
syncKey: asSyncKey,
hash: asOptional(asNonEmptyString)
});
const asGetStoreResponse = asObject({
hash: asOptional(asString),
changes: asChangeSet
});
// POST /v2/store
const asPostStoreParams = asObject({
syncKey: asSyncKey,
hash: asOptional(asNonEmptyString)
});
const asPostStoreBody = asObject({
changes: asChangeSet
});
const asPostStoreResponse = asObject({
hash: asString,
changes: asChangeSet
});
// PUT /v2/store
const asPutStoreParams = asObject({
syncKey: asSyncKey
});
const asPutStoreResponse = asUndefined;
/**
* Could not reach the server at all.
*/
class ConflictError extends Error {
constructor(opts) {
const { repoId } = opts;
super(`Repo ${repoId} already exists`);
this.name = 'ConflictError';
this.repoId = repoId;
}
}
const asMaybeConflictError = (
raw
) => {
if (raw instanceof Error && raw.name === 'ConflictError') return raw
};
const base58Codec = baseX(
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
);
const syncKeyToRepoId = (syncKey) => {
const bytes = base16.parse(syncKey);
const hashBytes = sha256(sha256(bytes));
return base58Codec.encode(hashBytes)
};
function sha256(data) {
const hash = hashjs.sha256();
return Uint8Array.from(hash.update(data).digest())
}
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]];
}
return array
}
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
const defaultEdgeServers = {
infoServers: ['https://info-eu1.edge.app', 'https://info-us1.edge.app'],
syncServers: [
'https://sync-us1.edge.app',
'https://sync-us2.edge.app',
'https://sync-us3.edge.app',
'https://sync-us4.edge.app',
'https://sync-us5.edge.app',
'https://sync-us6.edge.app',
'https://sync-eu.edge.app'
]
};
function makeSyncClient(opts = {}) {
const { fetch = crossFetch, log = () => {} } = opts;
const syncServers =
_nullishCoalesce(_optionalChain([opts, 'access', _ => _.edgeServers, 'optionalAccess', _2 => _2.syncServers]), () => ( defaultEdgeServers.syncServers));
// Returns the sync servers from the info client shuffled
async function shuffledSyncServers() {
return shuffle(syncServers)
}
async function loggedRequest(opts) {
const { method, url, body, numbUrl = url, headers = {} } = opts;
const start = Date.now();
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body
});
const timeElapsed = Date.now() - start;
log(`${method} ${numbUrl} returned ${response.status} in ${timeElapsed}ms`);
return response
}
async function unpackResponse(
request,
response,
asApiResponse
) {
const { method, url, numbUrl = url } = request;
const responseBody = await response.text();
if (!response.ok)
throw new Error(
`Failed request ${method} ${numbUrl} failed ${response.status}: ${responseBody}`
)
const errorResponse = asMaybe(asServerErrorResponse)(responseBody);
if (errorResponse != null) {
throw new Error(
`Failed request ${method} ${numbUrl} failed ${response.status}: ${errorResponse.message}`
)
}
const responseData = asApiResponse(
responseBody.trim() !== '' ? JSON.parse(responseBody) : undefined
);
return responseData
}
return {
async createRepo(syncKey, apiKey) {
const syncServers = await shuffledSyncServers();
let error = new Error(
`Failed to create repo ${syncKey}: empty sync server list`
);
for (const syncServer of syncServers) {
const repoId = syncKeyToRepoId(syncKey);
const url = `${syncServer}/api/v2/store/${syncKey}`;
const request = {
method: 'PUT',
url,
numbUrl: url.replace(syncKey, `<${repoId}>`),
headers: apiKey != null ? { 'X-API-Key': apiKey } : {}
};
try {
const response = await loggedRequest(request);
if (response.status === 409) throw new ConflictError({ repoId })
return await unpackResponse(request, response, asPutStoreResponse)
} catch (err) {
error = err;
}
}
throw error
},
async readRepo(syncKey, lastHash) {
const syncServers = await shuffledSyncServers();
let error = new Error(
`Failed to read repo ${syncKey}: empty sync server list`
);
for (const syncServer of syncServers) {
const url = `${syncServer}/api/v2/store/${syncKey}/${_nullishCoalesce(lastHash, () => ( ''))}`;
const request = {
method: 'GET',
url,
numbUrl: url.replace(syncKey, `<${syncKeyToRepoId(syncKey)}>`)
};
try {
const response = await loggedRequest(request);
return await unpackResponse(request, response, asGetStoreResponse)
} catch (err) {
error = err;
}
}
throw error
},
async updateRepo(syncKey, lastHash, body) {
const syncServers = await shuffledSyncServers();
let error = new Error(
`Failed to update repo ${syncKey}: empty sync server list`
);
for (const syncServer of syncServers) {
const url = `${syncServer}/api/v2/store/${syncKey}/${_nullishCoalesce(lastHash, () => ( ''))}`;
const request = {
method: 'POST',
url,
body: JSON.stringify(wasPostStoreBody(body)),
numbUrl: url.replace(syncKey, `<${syncKeyToRepoId(syncKey)}>`)
};
try {
const response = await loggedRequest(request);
return await unpackResponse(request, response, asPostStoreResponse)
} catch (err) {
error = err;
}
}
throw error
}
}
}
const wasPostStoreBody = uncleaner(asPostStoreBody);
export { ConflictError, asChangeSet, asEdgeBox, asEdgeServers, asFileChange, asGetStoreParams, asGetStoreResponse, asMaybeConflictError, asNonEmptyString, asPath, asPostStoreBody, asPostStoreParams, asPostStoreResponse, asPutStoreParams, asPutStoreResponse, asServerErrorResponse, asSyncKey, makeSyncClient, syncKeyToRepoId };