@casual-simulation/aux-common
Version:
Common library for AUX projects
523 lines • 19.9 kB
JavaScript
import { filter, map, switchMap, tap, finalize, first, scan, } from 'rxjs/operators';
import { merge, of, Subject } from 'rxjs';
export const DEFAULT_BRANCH_NAME = 'default';
/**
* Defines a client for inst records.
*/
export class InstRecordsClient {
get onSyncUpdatesEvent() {
return this._onSyncUpdatesEvent;
}
/**
* Gets the amount of time in miliseconds that the client should wait before resending updates that were never acknowledged.
* If null, then the client will never resend updates based on time.
*/
get resendUpdatesAfterMs() {
return this._resendUpdatesAfter;
}
/**
* Sets the amount of time in miliseconds that the client should wait before resending updates that were never acknowledged.
* If null, then the client will never resend updates based on time.
*/
set resendUpdatesAfterMs(value) {
this._resendUpdatesAfter = value;
}
get resendUpdatesIntervalMs() {
return this._resendUpdatesInterval;
}
set resendUpdatesIntervalMs(value) {
this._resendUpdatesInterval = value;
if (this._resendUpdatesInterval) {
this._startResendUpdatesInterval();
}
else {
this._stopResendUpdatesInterval();
}
}
_stopResendUpdatesInterval() {
if (this._resendUpdatesIntervalId) {
clearInterval(this._resendUpdatesIntervalId);
}
}
_startResendUpdatesInterval() {
this._stopResendUpdatesInterval();
this._resendUpdatesIntervalId = setInterval(() => {
this._resendUpdates();
}, this._resendUpdatesInterval);
}
_resendUpdates() {
for (let [branchKey, updates] of this._sentUpdates) {
for (let [updateId, sentUpdate] of updates) {
const lastTryTime = sentUpdate.lastTryTimeMs;
const retryAfter = this._resendUpdatesAfter *
Math.pow(2, Math.min(sentUpdate.tryCount - 1, 3));
const now = Date.now();
if (lastTryTime + retryAfter <= now) {
sentUpdate.tryCount += 1;
sentUpdate.lastTryTimeMs = now;
let [recordName, inst, branch] = branchKey.split('/');
if (!recordName) {
recordName = null;
}
this._sendAddUpdates(recordName, inst, branch, sentUpdate.updates, updateId);
}
}
}
}
constructor(connection) {
this._updateCounter = 0;
this._timeSyncCounter = 0;
this._resendUpdatesAfter = null;
this._resendUpdatesInterval = null;
this._resendUpdatesIntervalId = null;
this._onSyncUpdatesEvent = new Subject();
this._client = connection;
this._forcedOffline = false;
this._sentUpdates = new Map();
this._connectedDevices = new Map();
this._watchedBranches = new Set();
this._connectedBranches = new Set();
this._connectedDeviceBranches = new Set();
}
/**
* Gets the connection that this client is using.
*/
get connection() {
return this._client;
}
get onError() {
return this._client.onError;
}
/**
* Gets whether the client is forcing the connection to be offline or not.
*/
get forcedOffline() {
return this._forcedOffline;
}
/**
* Sets whether the client is forcing the connection to be offline or not.
*/
set forcedOffline(value) {
if (value === this._forcedOffline) {
return;
}
this._forcedOffline = value;
if (this._forcedOffline) {
this._client.disconnect();
}
else {
this._client.connect();
}
}
/**
* Starts watching the given branch.
* @param name The name of the branch to watch.
*/
watchBranchUpdates(nameOrEvent) {
var _a;
let branchEvent;
if (typeof nameOrEvent === 'string') {
branchEvent = {
type: 'repo/watch_branch',
recordName: null,
inst: nameOrEvent,
branch: DEFAULT_BRANCH_NAME,
};
}
else {
branchEvent = {
...nameOrEvent,
};
}
const recordName = (_a = branchEvent.recordName) !== null && _a !== void 0 ? _a : null;
const inst = branchEvent.inst;
const branch = branchEvent.branch;
const watchedBranchKey = branchKey(recordName, inst, branch);
this._watchedBranches.add(watchedBranchKey);
return this._whenConnected().pipe(tap((connected) => {
if (connected &&
this._connectedBranches.has(watchedBranchKey)) {
this._connectedBranches.delete(watchedBranchKey);
this._client.send({
type: 'repo/unwatch_branch',
recordName,
inst,
branch,
});
}
this._client.send(branchEvent);
}), switchMap((connected) => merge(this._client.event('repo/watch_branch_result').pipe(filter((event) => event.recordName === recordName &&
event.inst === inst &&
event.branch === branch), tap((e) => {
this._connectedBranches.add(watchedBranchKey);
if (e.success) {
let list = this._getSentUpdates(recordName, inst, branch);
for (let [key, value] of list) {
this._sendAddUpdates(recordName, inst, branch, value.updates, key);
}
}
})), this._client.event('repo/add_updates').pipe(filter((event) => event.recordName === recordName &&
event.inst === inst &&
event.branch === branch), scan((acc, event) => {
// This is the first event
if (acc[0] === null) {
// first event is initial event,
// skip to processing events
if (event.initial) {
return ['event', event];
}
else {
// first event is not initial.
// store it for later.
return ['waiting', [event]];
}
}
else if (acc[0] === 'waiting') {
// This event is happening while we are waiting for the initial event.
const events = acc[1];
if (event.initial) {
// current event is initial event,
// merge events.
const allEvents = events;
const allUpdates = allEvents.flatMap((e) => { var _a; return (_a = e.updates) !== null && _a !== void 0 ? _a : []; });
return [
'event',
{
branch: event.branch,
updates: [
...allUpdates,
...event.updates,
],
},
];
}
else {
// current event is not initial,
// store event
return [
'waiting',
[...events, event],
];
}
}
else {
// This event is happening after we have got the initial event
return ['event', event];
}
}, [null]), filter(([type, event]) => type === 'event'), map(([type, event]) => event), map((e) => ({
type: 'updates',
updates: e.updates,
}))), this._client.event('repo/updates_received').pipe(filter((event) => event.recordName === recordName &&
event.inst === inst &&
event.branch === branch), tap((event) => {
if (branchEvent.temporary) {
return;
}
// TODO: Decide whether to mark off the updates
// as saved or not when an error occurs.
// Right now, if the the updates are not stored on the server
// because too much space is used, then they will never be sent back to the server again.
let list = this._getSentUpdates(recordName, inst, branch);
list.delete(event.updateId);
if (list.size === 0) {
this._onSyncUpdatesEvent.next({
type: 'synced',
recordName,
inst,
branch,
});
}
}), map((event) => {
if (event.errorCode === 'max_size_reached') {
return {
type: 'error',
kind: event.errorCode,
maxBranchSizeInBytes: event.maxBranchSizeInBytes,
neededBranchSizeInBytes: event.neededBranchSizeInBytes,
};
}
return {
type: 'updates_received',
};
})), this._client.event('repo/receive_action').pipe(filter((event) => event.recordName === recordName &&
event.inst === inst &&
event.branch === branch), map((event) => ({
type: 'event',
action: event.action,
}))), this._client.onError.pipe(filter((error) => error.recordName === recordName &&
error.inst === inst &&
error.branch === branch), map((error) => ({
type: 'error',
kind: 'error',
info: error,
})))).pipe(filter(isClientUpdatesOrEvents))), finalize(() => {
this._watchedBranches.delete(watchedBranchKey);
this._connectedBranches.delete(watchedBranchKey);
if (this._client.isConnected) {
this._client.send({
type: 'repo/unwatch_branch',
recordName,
inst,
branch,
});
}
}));
}
/**
* Watches for rate limit exceeded events.
*/
watchRateLimitExceeded() {
return this._whenConnected().pipe(switchMap(() => this._client.event('rate_limit_exceeded')));
}
/**
* Gets the updates stored on the given branch.
* @param recordName The name of the record.
* @param inst The name of the inst.
* @param name The name of the branch to get.
*/
getBranchUpdates(recordName, inst, branch) {
return this._whenConnected().pipe(first((connected) => connected), tap((connected) => {
this._client.send({
type: 'repo/get_updates',
recordName,
inst,
branch,
});
}), switchMap((connected) => this._client
.event('repo/add_updates')
.pipe(first((event) => event.recordName === recordName &&
event.inst === inst &&
event.branch === branch))));
}
/**
* Watches for device connection/disconnection events on the given branch.
* @param branch The branch to watch.
*/
watchBranchDevices(recordName, inst, branch) {
return this._whenConnected(false).pipe(switchMap((connected) =>
// Grab all of the currently connected devices
// and send disconnected events for them
!connected
? this._disconnectDevices(recordName, inst, branch)
: this._watchConnectedDevices(recordName, inst, branch)));
}
_disconnectDevices(recordName, inst, branch) {
return of(...[
...this._getConnectedDevices(recordName, inst, branch).values(),
].map((device) => ({
type: 'repo/disconnected_from_branch',
broadcast: false,
recordName,
inst,
branch: branch,
connection: device,
})));
}
_watchConnectedDevices(recordName, inst, branch) {
const watchedBranchKey = branchKey(recordName, inst, branch);
return of(true).pipe(tap((connected) => {
if (connected &&
this._connectedDeviceBranches.has(watchedBranchKey)) {
this._client.send({
type: 'repo/unwatch_branch_devices',
recordName,
inst,
branch,
});
}
this._connectedDeviceBranches.add(watchedBranchKey);
this._client.send({
type: 'repo/watch_branch_devices',
recordName,
inst,
branch,
});
}), switchMap((connected) => merge(this._client.event('repo/connected_to_branch').pipe(filter((e) => e.broadcast === false &&
e.branch.recordName === recordName &&
e.branch.inst === inst &&
e.branch.branch === branch &&
!this._isDeviceConnected(recordName, inst, branch, e.connection)), tap((e) => {
const devices = this._getConnectedDevices(recordName, inst, branch);
devices.set(e.connection.connectionId, e.connection);
}), map((e) => ({
type: 'repo/connected_to_branch',
...e,
}))), this._client.event('repo/disconnected_from_branch').pipe(filter((e) => e.broadcast === false &&
e.recordName === recordName &&
e.inst === inst &&
e.branch === branch &&
this._isDeviceConnected(recordName, inst, branch, e.connection)), tap((e) => {
const devices = this._getConnectedDevices(recordName, inst, branch);
devices.delete(e.connection.connectionId);
}), map((e) => ({
type: 'repo/disconnected_from_branch',
...e,
}))))), finalize(() => {
this._connectedDeviceBranches.delete(watchedBranchKey);
if (this._client.isConnected) {
this._client.send({
type: 'repo/unwatch_branch_devices',
recordName,
inst,
branch,
});
}
}));
}
/**
* Adds the given updates to the given branch.
* @param recordName The name of the record.
* @param inst The name of the inst.
* @param branch The name of the branch.
* @param updates The updates.
*/
addUpdates(recordName, inst, branch, updates) {
if (updates.length <= 0) {
return;
}
let list = this._getSentUpdates(recordName, inst, branch);
this._updateCounter += 1;
list.set(this._updateCounter, {
updates,
updateId: this._updateCounter,
sentTimeMs: Date.now(),
lastTryTimeMs: Date.now(),
tryCount: 1,
});
this._sendAddUpdates(recordName, inst, branch, updates, this._updateCounter);
if (list.size === 1) {
this._onSyncUpdatesEvent.next({
type: 'syncing',
recordName,
inst,
branch,
});
}
}
/**
* Sends the given action to devices on the given branch.
* @param branch The branch.
* @param action The action.
*/
sendAction(recordName, inst, branch, action) {
this._client.send({
type: 'repo/send_action',
recordName,
inst,
branch,
action,
});
}
/**
* Sends a SyncTimeRequest to the server.
*/
sampleServerTime() {
let count = this._timeSyncCounter + 1;
this._timeSyncCounter = count;
const observable = this._whenConnected().pipe(first((c) => c), tap((connected) => {
this._client.send({
type: 'sync/time',
id: count,
clientRequestTime: Date.now(),
});
}), switchMap((connected) => this._client.event('sync/time/response').pipe(first((event) => event.id === count), map((r) => ({
clientRequestTime: r.clientRequestTime,
currentTime: Date.now(),
serverReceiveTime: r.serverReceiveTime,
serverTransmitTime: r.serverTransmitTime,
})))));
return new Promise((resolve, reject) => {
observable.subscribe({
next: (o) => resolve(o),
error: (err) => reject(err),
});
});
}
/**
* Requests the number of devices that are currently connected.
* @param branch The branch that the devices should be counted on.
*/
connectionCount(recordName = null, inst = null, branch = null) {
recordName = recordName !== null && recordName !== void 0 ? recordName : null;
inst = inst !== null && inst !== void 0 ? inst : null;
branch = branch !== null && branch !== void 0 ? branch : null;
return this._whenConnected().pipe(tap((connected) => {
this._client.send({
type: 'repo/connection_count',
recordName,
inst,
branch,
});
}), switchMap((connected) => merge(this._client
.event('repo/connection_count')
.pipe(first((e) => e.recordName === recordName &&
e.inst === inst &&
e.branch === branch)))), map((e) => e.count));
}
_whenConnected(filter = true) {
return whenConnected(this._client.connectionState, filter);
}
_sendAddUpdates(recordName, inst, branch, updates, updateId) {
if (this._watchedBranches.has(branch) && !this.connection.isConnected) {
// Skip sending the atoms because we're watching the branch and we're not connected.
// This means that the new atoms are saved in the sent atoms list so they will be resent
// when we reconnect.
return;
}
this._client.send({
type: 'repo/add_updates',
recordName,
inst,
branch,
updates,
updateId,
});
}
_getSentUpdates(recordName, inst, branch) {
const key = branchKey(recordName, inst, branch);
let map = this._sentUpdates.get(key);
if (!map) {
map = new Map();
this._sentUpdates.set(key, map);
}
return map;
}
_getConnectedDevices(recordName, inst, branch) {
const key = branchKey(recordName, inst, branch);
let map = this._connectedDevices.get(key);
if (!map) {
map = new Map();
this._connectedDevices.set(key, map);
}
return map;
}
_isDeviceConnected(recordName, inst, branch, device) {
const map = this._getConnectedDevices(recordName, inst, branch);
return map.has(device.connectionId);
}
}
export function isClientEvent(event) {
return event.type === 'event';
}
export function isClientUpdates(event) {
return event.type === 'updates';
}
export function isClientUpdatesOrEvents(event) {
return (event.type === 'updates' ||
event.type === 'event' ||
event.type === 'error' ||
event.type === 'repo/watch_branch_result');
}
export function isClientError(event) {
return event.type === 'error';
}
export function isWatchBranchResult(event) {
return event.type === 'repo/watch_branch_result';
}
function whenConnected(observable, filterConnected = true) {
return observable.pipe(map((s) => s.connected), filterConnected ? filter((connected) => connected) : (a) => a);
}
function branchKey(recordName, inst, branch) {
return `${recordName !== null && recordName !== void 0 ? recordName : ''}/${inst}/${branch}`;
}
//# sourceMappingURL=InstRecordsClient.js.map