UNPKG

@foxy.io/sdk

Version:

Universal SDK for a full server-side and a limited in-browser access to Foxy hAPI.

166 lines (165 loc) 6.92 kB
import { get, isEqual } from "lodash-es"; import { UpdateError } from './UpdateError.js'; import traverse from 'traverse'; /** * Rumour is a schemaless state manager for `application/hal+json` resources. * * Unlike many other state managers, Rumour neither stores a global state nor * knows its structure – instead it links multiple local HAL+JSON states together * and keeps them in sync. * * To get started, subscribe your local state to Rumour updates using * the `.track()` method, and then subscribe Rumour to the local state updates * using the `.share()` method. Rumour will run the callback function passed to `.track()` * whenever an update becomes available, and if that update applies to your local state, * Rumour will recursively patch it on demand when you invoke `ctx.update()` from the callback. * * If you no longer need to receive updates, remember to unsubscribe * to avoid memory leaks (see `.track()` docs for more info). * * All updates are scoped to a Rumour instance, so if you share a resource to an * instance, only its own trackers will be notified. */ export class Rumour { constructor() { this.__callbacks = []; } /** * Extracts updates from the given resource and sends them to the * appropriate listeners. * * ``` * // on creation * rumour.share({ * related: ['https://example.com/foos'], // collection URI * source: 'https://example.com/foos/0', * data: { foo: 'bar' }, * }); * * // on update * rumour.share({ * source: 'https://example.com/foos/0', * data: { foo: 'bar' } * }); * * // on deletion * rumour.share({ * source: 'https://example.com/foos/0', * data: null * }); * ``` * * @param params Resource metadata and contents. */ share(params) { const { related, source, data } = params; const patch = data === null ? new Map([[source, null]]) : Rumour.__createPatch(data); [...this.__callbacks].forEach(callback => { callback(oldData => { const newData = Rumour.__applyPatch(patch, oldData, related); return isEqual(oldData, newData) ? oldData : newData; }); }); } /** * Subscribes to updates, returning a function that you can call * later to unsubscribe. Note that your listener will be notified of * every update, even if it doesn't apply to your resource – be sure to call * the update function provided as the first argument of the callback to see if * there are any changes. * * ``` * const unsubscribe = rumour.track(update => { * try { * const newResource = update(oldResource); * if (oldResource !== newResource) renderView(newResource); * } catch { * if (err instanceof Rumour.UpdateError) reloadFromServer(); * } * }); * * // later in your code when you no longer need Rumour: * unsubscribe(); * ``` * * @param callback Function that will be called on every update. * @returns Function that you can call to unsubscribe from updates. */ track(callback) { this.__callbacks.push(callback); return () => void this.__callbacks.splice(this.__callbacks.indexOf(callback), 1); } /** Removes all update listeners. */ cease() { this.__callbacks.length = 0; } static __isCollection(json) { return typeof get(json, '_links.first.href') === 'string'; } static __isResource(json) { return typeof get(json, '_links.self.href') === 'string'; } static __approximateURI(href) { try { const url = new URL(href); url.search = ''; url.hash = ''; return url.toString(); } catch (_a) { return href; } } static __createPatch(data) { return traverse(data).reduce(function (patch, value) { if (!Rumour.__isResource(value) || Rumour.__isCollection(value)) return patch; const props = traverse(value).map(function () { if (this.key === '_embedded') return this.delete(true); }); patch.set(value._links.self.href, props); return patch; }, new Map()); } static __applyPatch(patch, data, related) { var _a, _b; const approximateRelatedURIs = (_a = related === null || related === void 0 ? void 0 : related.map(uri => Rumour.__approximateURI(uri))) !== null && _a !== void 0 ? _a : []; const result = traverse({ data }).map(function (node) { if (!Rumour.__isResource(node)) return; const exactURI = node._links.self.href; const approximateURI = Rumour.__approximateURI(exactURI); // This resource was referenced in the `related` array and is considered to be somehow affected by the update. // An error is usually thrown here if a resource is added to a collection – Rumour can't know for sure // where to insert the new resource in a collection page, so it asks the data host to reload its state. if (approximateRelatedURIs.includes(approximateURI)) throw new Rumour.UpdateError(); if (patch.has(exactURI)) { const props = patch.get(exactURI); // When props ending with `_uri` or `_id` change, it's possible that the embedded content // will change as well, so we throw an error to ask the data host to reload its state. if (props) { for (const key in props) { if (!key.endsWith('_uri') && !key.endsWith('_id')) continue; if (node[key] === props[key]) continue; throw new Rumour.UpdateError(); } } if (props) return this.update(Object.assign(Object.assign({}, node), props), true); if (!this.parent.parent) return this.delete(true); // Deleting an embedded resource may result in differences between server-side and client-side states. // For example, deleting a resource from a collection page shifts the content and changes some of the page fields. // At this point, Rumour is unable to perform such updates reliably, so it asks the data host to reload its state instead. throw new Rumour.UpdateError(); } }); return (_b = result.data) !== null && _b !== void 0 ? _b : null; } } /** Error thrown when there isn't enough data to update local state automatically. */ Rumour.UpdateError = UpdateError;