@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
JavaScript
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;