UNPKG

edge-sync-client

Version:

Library for accessing the Edge data-sync system

372 lines (269 loc) 9.48 kB
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 };