edge-sync-client
Version:
Library for accessing the Edge data-sync system
400 lines (293 loc) • 10.7 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var cleaners = require('cleaners');
var crossFetch = require('cross-fetch');
var baseX = require('base-x');
var hashjs = require('hash.js');
var rfc4648 = require('rfc4648');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var crossFetch__default = /*#__PURE__*/_interopDefaultLegacy(crossFetch);
var baseX__default = /*#__PURE__*/_interopDefaultLegacy(baseX);
var hashjs__default = /*#__PURE__*/_interopDefaultLegacy(hashjs);
/**
* 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 = cleaners.asObject({
infoServers: cleaners.asOptional(cleaners.asArray(cleaners.asString)),
syncServers: cleaners.asOptional(cleaners.asArray(cleaners.asString))
});
//
// Primitive Types
//
const asNonEmptyString = (raw) => {
const str = cleaners.asString(raw);
if (str === '') {
throw new TypeError('Expected non empty string')
}
return str
};
const asPath = (raw) => {
const path = cleaners.asString(raw);
try {
if (VALID_PATH_REGEX.test(path)) return normalizePath(path)
} catch (_) {}
throw new Error(`Invalid path '${path}'`)
};
const asSyncKey = (raw) => {
const syncKey = cleaners.asString(raw);
if (!VALID_SYNC_KEY_REGEX.test(syncKey)) {
throw new TypeError(`Invalid sync key '${syncKey}'`)
}
return syncKey
};
const asEdgeBox = cleaners.asObject({
iv_hex: cleaners.asString,
encryptionType: cleaners.asNumber,
data_base64: cleaners.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 = cleaners.asObject({
success: cleaners.asValue(false),
message: cleaners.asString,
stack: cleaners.asOptional(cleaners.asString)
});
const asFileChange = cleaners.asEither(asEdgeBox, cleaners.asNull);
const asChangeSet = cleaners.asObject(asFileChange);
// GET /v2/store
const asGetStoreParams = cleaners.asObject({
syncKey: asSyncKey,
hash: cleaners.asOptional(asNonEmptyString)
});
const asGetStoreResponse = cleaners.asObject({
hash: cleaners.asOptional(cleaners.asString),
changes: asChangeSet
});
// POST /v2/store
const asPostStoreParams = cleaners.asObject({
syncKey: asSyncKey,
hash: cleaners.asOptional(asNonEmptyString)
});
const asPostStoreBody = cleaners.asObject({
changes: asChangeSet
});
const asPostStoreResponse = cleaners.asObject({
hash: cleaners.asString,
changes: asChangeSet
});
// PUT /v2/store
const asPutStoreParams = cleaners.asObject({
syncKey: asSyncKey
});
const asPutStoreResponse = cleaners.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__default['default'](
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
);
const syncKeyToRepoId = (syncKey) => {
const bytes = rfc4648.base16.parse(syncKey);
const hashBytes = sha256(sha256(bytes));
return base58Codec.encode(hashBytes)
};
function sha256(data) {
const hash = hashjs__default['default'].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__default['default'], 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 = cleaners.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 = cleaners.uncleaner(asPostStoreBody);
exports.ConflictError = ConflictError;
exports.asChangeSet = asChangeSet;
exports.asEdgeBox = asEdgeBox;
exports.asEdgeServers = asEdgeServers;
exports.asFileChange = asFileChange;
exports.asGetStoreParams = asGetStoreParams;
exports.asGetStoreResponse = asGetStoreResponse;
exports.asMaybeConflictError = asMaybeConflictError;
exports.asNonEmptyString = asNonEmptyString;
exports.asPath = asPath;
exports.asPostStoreBody = asPostStoreBody;
exports.asPostStoreParams = asPostStoreParams;
exports.asPostStoreResponse = asPostStoreResponse;
exports.asPutStoreParams = asPutStoreParams;
exports.asPutStoreResponse = asPutStoreResponse;
exports.asServerErrorResponse = asServerErrorResponse;
exports.asSyncKey = asSyncKey;
exports.makeSyncClient = makeSyncClient;
exports.syncKeyToRepoId = syncKeyToRepoId;