Start adding real-time collab support to WYSIWYG
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
8f7ff1de73
commit
c7f9a59cc4
14
capacitor.config.json
Normal file
14
capacitor.config.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"appId": "io.ionic.starter",
|
||||
"appName": "frontend",
|
||||
"bundledWebRuntime": false,
|
||||
"npmClient": "npm",
|
||||
"webDir": "www",
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 0
|
||||
}
|
||||
},
|
||||
"cordova": {},
|
||||
"linuxAndroidStudioPath": "/home/garrettmills/.local/android-studio/bin/studio.sh"
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
"/link_api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"ws": true,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": {"^/link_api": ""}
|
||||
}
|
||||
|
@ -2,6 +2,10 @@
|
||||
<wysiwyg-editor
|
||||
[contents]="contents"
|
||||
(contentsChanged)="onContentsChanged($event)"
|
||||
(selectionChanged)="onSelectionChanged($event)"
|
||||
[readonly]="isReadonly"
|
||||
[editingUsers]="editorGroupUsers"
|
||||
[editingUserSelections]="editingUserSelections"
|
||||
[requestSelectionRefresh]="requestSelectionRefresh"
|
||||
></wysiwyg-editor>
|
||||
</div>
|
@ -1,13 +1,17 @@
|
||||
import {Component, HostListener, Input, OnInit, ViewChild} from '@angular/core';
|
||||
import {Component, HostListener, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {EditorNodeContract} from '../EditorNode.contract';
|
||||
import {EditorService} from '../../../service/editor.service';
|
||||
import {FlitterSocketConnection, FlitterSocketServerClientTransaction} from '../../../flitter-socket';
|
||||
import {ApiService} from '../../../service/api.service';
|
||||
import {debug} from '../../../utility';
|
||||
import { EditingUserSelect } from '../../wysiwyg/wysiwyg.component';
|
||||
|
||||
@Component({
|
||||
selector: 'editor-norm',
|
||||
templateUrl: './norm.component.html',
|
||||
styleUrls: ['./norm.component.scss'],
|
||||
})
|
||||
export class NormComponent extends EditorNodeContract implements OnInit {
|
||||
export class NormComponent extends EditorNodeContract implements OnInit, OnDestroy {
|
||||
@ViewChild('editable') editable;
|
||||
@Input() nodeId: string;
|
||||
@Input() editorUUID?: string;
|
||||
@ -16,13 +20,23 @@ export class NormComponent extends EditorNodeContract implements OnInit {
|
||||
protected savedValue = 'Click to edit...';
|
||||
public contents = '';
|
||||
private dirtyOverride = false;
|
||||
private editorGroupSocket?: FlitterSocketConnection;
|
||||
|
||||
public editorGroupUsers: Array<{uuid: string, uid: string, display: string, color: string}> = [];
|
||||
public editorGroupId?: string;
|
||||
protected editingUserSelections: Array<EditingUserSelect> = [];
|
||||
|
||||
public requestSelectionRefresh = () => this.refreshRemoteSelections();
|
||||
|
||||
constructor(
|
||||
public editorService: EditorService,
|
||||
public readonly api: ApiService,
|
||||
) {
|
||||
super();
|
||||
this.contents = this.initialValue;
|
||||
this.savedValue = this.initialValue;
|
||||
|
||||
console.log('Norm editor component', this);
|
||||
}
|
||||
|
||||
public isDark() {
|
||||
@ -58,10 +72,92 @@ export class NormComponent extends EditorNodeContract implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
debug('ngOnDestroy in Norm editor component');
|
||||
if ( this.editorGroupSocket && this.editorGroupId ) {
|
||||
debug('Closing editor socket...');
|
||||
this.editorGroupSocket.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
onContentsChanged(contents: string) {
|
||||
if ( this.contents !== contents ) {
|
||||
this.contents = contents;
|
||||
this.editorService.triggerSave();
|
||||
}
|
||||
}
|
||||
|
||||
public needsLoad(): boolean | Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async performLoad(): Promise<void> {
|
||||
// This is called after the Node record has been loaded.
|
||||
|
||||
// FIXME need to find a consistent way of doing this on prod/development
|
||||
// FIXME Probably make use of the systemBase, but allow overriding it in the environment
|
||||
// const url = this.api._build_url('socket/norm-editor');
|
||||
const url = 'ws://localhost:8000/api/v1/socket/norm-editor';
|
||||
debug(`Norm editor socket URL: ${url}`);
|
||||
|
||||
const socket = new FlitterSocketConnection(url);
|
||||
socket.controller(this);
|
||||
|
||||
await socket.on_open();
|
||||
debug('Connected to norm editor socket', socket);
|
||||
|
||||
const [transaction2, socket2, { editor_group_id }] = await socket.asyncRequest('join_editor_group', {
|
||||
NodeId: this.node.UUID,
|
||||
PageId: this.page.UUID,
|
||||
});
|
||||
|
||||
this.editorGroupSocket = socket;
|
||||
|
||||
const [transaction3, socket3, users = []] = await socket.asyncRequest('get_editor_group_users', { editor_group_id });
|
||||
if ( Array.isArray(users) ) {
|
||||
this.editorGroupUsers = users;
|
||||
}
|
||||
|
||||
this.editorGroupId = editor_group_id;
|
||||
await this.refreshRemoteSelections();
|
||||
}
|
||||
|
||||
setEditorGroupUsers(transaction: FlitterSocketServerClientTransaction, socket: any) {
|
||||
if ( Array.isArray(transaction?.incoming?.users) ) {
|
||||
this.editorGroupUsers = transaction.incoming.users;
|
||||
debug('Refreshed norm editor group users.');
|
||||
transaction.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
setEditorGroupSelections(transaction: FlitterSocketServerClientTransaction, socket: any) {
|
||||
if ( Array.isArray(transaction?.incoming?.selections) ) {
|
||||
debug('Got selections', transaction.incoming.selections);
|
||||
this.editingUserSelections = transaction.incoming.selections;
|
||||
transaction.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async onSelectionChanged(selection: { path: string, offset: number }) {
|
||||
if ( this.editorGroupSocket && this.editorGroupId ) {
|
||||
await this.editorGroupSocket.asyncRequest('set_member_selection', {
|
||||
editor_group_id: this.editorGroupId,
|
||||
selection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRemoteSelections() {
|
||||
if ( this.editorGroupSocket && this.editorGroupId ) {
|
||||
const [
|
||||
transaction, _, data
|
||||
] = await this.editorGroupSocket.asyncRequest('get_selections', {
|
||||
editor_group_id: this.editorGroupId,
|
||||
});
|
||||
|
||||
if ( Array.isArray(data?.selections) ) {
|
||||
this.editingUserSelections = data.selections;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,13 @@
|
||||
<div [ngClass]="isDark() ? 'container dark' : 'container'"
|
||||
(dblclick)="onFocusIn($event)">
|
||||
<div class="toolbar-base" *ngIf="editingUsers && editingUsers.length">
|
||||
<div class="editing-msg">Also editing:</div>
|
||||
<div class="editing-user"
|
||||
*ngFor="let user of editingUsers"
|
||||
[ngStyle]="{background: user.color, color: getContrastYIQ(user.color.slice(1))}"
|
||||
[title]="user.display"
|
||||
>{{ user.display.charAt(0).toUpperCase() }}</div>
|
||||
</div>
|
||||
<div class="toolbar-base" *ngIf="isFocused">
|
||||
<button class="toolbar-button" title="Bold" (click)="documentCommand('bold')">
|
||||
<i class="icon fa fa-bold"></i>
|
||||
@ -65,16 +73,25 @@
|
||||
―
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="editable-base"
|
||||
[ngClass]="isFocused ? 'focused' : ''"
|
||||
contenteditable
|
||||
appDomChange
|
||||
*ngIf="isFocused"
|
||||
[innerHTML]="contents"
|
||||
#editable
|
||||
(domChange)="onContentsChanged($event)"
|
||||
></div>
|
||||
<div class="editable-container">
|
||||
<div class="remote-cursors-container" *ngIf="editable && editingUserSelections && editingUserSelections.length">
|
||||
<div class="remote-cursor"
|
||||
*ngFor="let sel of editingUserSelections"
|
||||
[ngStyle]="{backgroundColor: sel.user_data.color, left: (sel.left || 0) + 'px', top: (sel.top || 0) + 'px'}"
|
||||
[title]="sel.user_data.display"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="editable-base"
|
||||
[ngClass]="isFocused ? 'focused' : ''"
|
||||
contenteditable
|
||||
appDomChange
|
||||
*ngIf="isFocused"
|
||||
[innerHTML]="contents"
|
||||
#editable
|
||||
(domChange)="onContentsChanged($event)"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="editable-base"
|
||||
*ngIf="!isFocused"
|
||||
|
@ -14,6 +14,28 @@
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.editing-user {
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
margin: 3px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.editing-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
height: calc(100% - 6px);
|
||||
min-width: 30px;
|
||||
@ -38,6 +60,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.remote-cursors-container {
|
||||
.remote-cursor {
|
||||
min-height: 20px;
|
||||
width: 3px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.container.dark {
|
||||
.editable-base.focused {
|
||||
background: #404040 !important;
|
||||
|
@ -1,4 +1,17 @@
|
||||
import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild} from '@angular/core';
|
||||
import {debug} from '../../utility';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
|
||||
export interface EditingUserSelect {
|
||||
path: string;
|
||||
offset: string;
|
||||
user_data: {
|
||||
uuid: string,
|
||||
uid: string,
|
||||
display: string,
|
||||
color: string
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'wysiwyg-editor',
|
||||
@ -8,6 +21,7 @@ import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild}
|
||||
export class WysiwygComponent implements OnInit {
|
||||
@ViewChild('editable') editable;
|
||||
@Input() readonly = false;
|
||||
@Input() requestSelectionRefresh?: () => any;
|
||||
@Input() set contents(val: string) {
|
||||
if ( this.isFocused ) {
|
||||
if ( this.editingContents !== val ) {
|
||||
@ -24,7 +38,20 @@ export class WysiwygComponent implements OnInit {
|
||||
return this.currentContents;
|
||||
}
|
||||
|
||||
@Input() editingUsers: Array<{uuid: string, uid: string, display: string, color: string}> = [];
|
||||
|
||||
protected privEditingUserSelections: Array<any> = [];
|
||||
@Input() set editingUserSelections(sels: Array<any>) {
|
||||
this.privEditingUserSelections = sels;
|
||||
this.processSelections();
|
||||
}
|
||||
|
||||
get editingUserSelections() {
|
||||
return this.privEditingUserSelections;
|
||||
}
|
||||
|
||||
@Output() contentsChanged: EventEmitter<string> = new EventEmitter<string>();
|
||||
@Output() selectionChanged: EventEmitter<{path: string, offset: number}> = new EventEmitter<{path: string; offset: number}>();
|
||||
|
||||
public currentContents = '';
|
||||
protected editingContents = '';
|
||||
@ -50,15 +77,109 @@ export class WysiwygComponent implements OnInit {
|
||||
.replace(/(https?:\/\/[^\s]+)/g, (val) => `<a href="${val}" target="_blank">${val}</a>`);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected sanitizer: DomSanitizer,
|
||||
) { }
|
||||
|
||||
public isDark() {
|
||||
return document.body.classList.contains('dark');
|
||||
}
|
||||
|
||||
ngOnInit() { }
|
||||
ngOnInit() {
|
||||
debug('Initializing WYSIWYG', this);
|
||||
|
||||
document.addEventListener('selectionchange', event => {
|
||||
this.selectionChanged.emit(this.getSerializedSelection());
|
||||
});
|
||||
}
|
||||
|
||||
onFocusIn(event: MouseEvent) {
|
||||
this.isFocused = !this.readonly;
|
||||
this.editingContents = this.currentContents;
|
||||
if ( this.requestSelectionRefresh ) {
|
||||
this.requestSelectionRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
getSerializedSelection() {
|
||||
const selection = window.getSelection();
|
||||
if ( this.editable && this.editable.nativeElement.contains(selection.focusNode) ) {
|
||||
return {
|
||||
path: this.getPathToElement(selection.focusNode),
|
||||
offset: selection.focusOffset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getElementFromPath(path: string) {
|
||||
if ( !this.editable ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = path.split('.')
|
||||
.map(x => x.split('#'))
|
||||
.map(([tagName, idxString]) => [tagName, Number(idxString)]);
|
||||
|
||||
let currentElem = this.editable.nativeElement;
|
||||
for ( const part of parts ) {
|
||||
if ( !currentElem ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [tagName, idx] = part;
|
||||
const children = [...currentElem.getElementsByTagName(tagName)];
|
||||
currentElem = children[idx];
|
||||
}
|
||||
|
||||
return currentElem;
|
||||
}
|
||||
|
||||
getPathToElement(elem: any) {
|
||||
if ( !this.editable ) {
|
||||
throw new Error('Cannot get path to element unless editable.');
|
||||
}
|
||||
|
||||
const maxNest = 5000;
|
||||
let currentNest = 0;
|
||||
const parents = [];
|
||||
let currentParent = elem;
|
||||
|
||||
do {
|
||||
currentNest += 1;
|
||||
if ( currentNest > maxNest ) {
|
||||
throw new Error('Reached max nesting limit.');
|
||||
}
|
||||
|
||||
currentParent = currentParent.parentElement;
|
||||
if ( currentParent ) {
|
||||
parents.push(currentParent);
|
||||
}
|
||||
} while ( currentParent && currentParent !== this.editable.nativeElement );
|
||||
|
||||
return parents.reverse()
|
||||
.slice(1)
|
||||
.map((element, idx) => {
|
||||
const siblings = element.parentElement.getElementsByTagName(element.tagName);
|
||||
let siblingIdx = -1;
|
||||
[...siblings].some((sibling, potentialIdx) => {
|
||||
if ( sibling === element ) {
|
||||
siblingIdx = potentialIdx;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return `${element.tagName}#${siblingIdx}`;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
processSelections() {
|
||||
for ( const sel of this.privEditingUserSelections ) {
|
||||
sel.element = this.getElementFromPath(sel.path);
|
||||
sel.top = sel.element.offsetTop;
|
||||
sel.left = sel.element.offsetLeft;
|
||||
console.log({sel, editable: this.editable});
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.escape', ['$event'])
|
||||
@ -133,4 +254,13 @@ export class WysiwygComponent implements OnInit {
|
||||
event.preventDefault();
|
||||
this.documentCommand('redo');
|
||||
}
|
||||
|
||||
getContrastYIQ(hexcolor) {
|
||||
hexcolor = hexcolor.replace('#', '');
|
||||
const r = parseInt(hexcolor.substr(0, 2), 16);
|
||||
const g = parseInt(hexcolor.substr(2, 2), 16);
|
||||
const b = parseInt(hexcolor.substr(4, 2), 16);
|
||||
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
return (yiq >= 128) ? 'black' : 'white';
|
||||
}
|
||||
}
|
||||
|
323
src/app/flitter-socket.ts
Normal file
323
src/app/flitter-socket.ts
Normal file
@ -0,0 +1,323 @@
|
||||
import {uuid_v4} from './utility';
|
||||
|
||||
const _FWS = WebSocket;
|
||||
|
||||
export class FlitterSocketTransaction {
|
||||
connection: any;
|
||||
type: string;
|
||||
id: string;
|
||||
resolved = false;
|
||||
socket: any;
|
||||
outgoing: any;
|
||||
incoming: any;
|
||||
connectionId: string;
|
||||
sent = false;
|
||||
received = false;
|
||||
// tslint:disable-next-line:variable-name
|
||||
_status = 200;
|
||||
// tslint:disable-next-line:variable-name
|
||||
_message = '';
|
||||
endpoint = '';
|
||||
json = '';
|
||||
|
||||
constructor(data, conn) {
|
||||
this.connection = conn;
|
||||
this.type = data.type;
|
||||
this.id = data.transaction_id ? data.transaction_id : conn.uuid();
|
||||
this.socket = conn.socket;
|
||||
this.outgoing = data.outgoing ? data.outgoing : {};
|
||||
this.incoming = data.incoming ? data.incoming : {};
|
||||
this.connectionId = conn.id;
|
||||
}
|
||||
|
||||
resolve() {
|
||||
this.resolved = true;
|
||||
}
|
||||
|
||||
status(code: number = null) {
|
||||
if ( code ) {
|
||||
this._status = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
return this._status;
|
||||
}
|
||||
|
||||
message(msg: string = null) {
|
||||
if ( msg ) {
|
||||
this._message = msg;
|
||||
return this;
|
||||
}
|
||||
|
||||
return this._message;
|
||||
}
|
||||
|
||||
send(data: any = null) {
|
||||
if ( this.type === 'response' ) {
|
||||
if ( this.resolved ) { throw new Error(`Transaction can only be sent once per request. (ID: ${this.id})`); }
|
||||
|
||||
const obj = {
|
||||
status: this._status,
|
||||
transaction_id: this.id,
|
||||
type: 'response',
|
||||
... (this._message && {message: this._message}),
|
||||
... (data && {data}),
|
||||
... ((!data && this.outgoing) && {data: this.outgoing})
|
||||
};
|
||||
|
||||
this.json = JSON.stringify(obj);
|
||||
this.resolve();
|
||||
} else if ( this.type === 'request' ) {
|
||||
if ( this.sent ) { throw new Error(`Request can only be sent once per Transaction. (ID: ${this.id})`); }
|
||||
|
||||
const obj = {
|
||||
endpoint: this.endpoint,
|
||||
transaction_id: this.id,
|
||||
type: 'request',
|
||||
... (data && {data}),
|
||||
... ((!data && this.outgoing) && {data: this.outgoing})
|
||||
};
|
||||
|
||||
this.json = JSON.stringify(obj);
|
||||
}
|
||||
|
||||
this.sent = true;
|
||||
console.log('Sending message...', this.json);
|
||||
return this.socket.send(this.json);
|
||||
}
|
||||
}
|
||||
|
||||
export class FlitterSocketClientServerTransaction extends FlitterSocketTransaction {
|
||||
// tslint:disable-next-line:variable-name
|
||||
_handler: any;
|
||||
|
||||
constructor(data, conn) {
|
||||
if ( data.data ) { data.outgoing = data.data; }
|
||||
super(data, conn);
|
||||
|
||||
this.type = 'request';
|
||||
if ( data.endpoint ) { this.endpoint = data.endpoint; }
|
||||
}
|
||||
|
||||
handler(fn) {
|
||||
if ( !fn ) { return this._handler; }
|
||||
|
||||
this._handler = fn;
|
||||
return this;
|
||||
}
|
||||
|
||||
receipt(data) {
|
||||
this.received = true;
|
||||
return this._handler(this, this.socket, data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class FlitterSocketServerClientTransaction extends FlitterSocketTransaction {
|
||||
constructor(data, conn) {
|
||||
if ( data.data ) { data.incoming = data.data; }
|
||||
super(data, conn);
|
||||
|
||||
this.type = 'response';
|
||||
if ( data.endpoint ) { this.endpoint = data.endpoint; }
|
||||
}
|
||||
|
||||
set(key, value = null) {
|
||||
if ( value && typeof this.outgoing === 'object' ) { this.outgoing[key] = value; } else { this.outgoing = key; }
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class FlitterSocketConnection {
|
||||
url: string;
|
||||
open = false;
|
||||
activeTransactions: any = {};
|
||||
id: string;
|
||||
// tslint:disable-next-line:variable-name
|
||||
_controller: any = {};
|
||||
closeResolves: any[] = [];
|
||||
closeCallbacks: any[] = [];
|
||||
openResolves: any[] = [];
|
||||
openCallbacks: any[] = [];
|
||||
socket: any;
|
||||
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.open = false;
|
||||
this.activeTransactions = {};
|
||||
this.id = this.uuid();
|
||||
this._controller = {};
|
||||
this._create_socket();
|
||||
this.closeResolves = [];
|
||||
this.closeCallbacks = [];
|
||||
this.openResolves = [];
|
||||
this.openCallbacks = [];
|
||||
}
|
||||
|
||||
on_close(callback = false) {
|
||||
if ( !callback ) {
|
||||
return new Promise(resolve => {
|
||||
this.closeResolves.push(resolve);
|
||||
});
|
||||
} else {
|
||||
this.closeCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
on_open(callback = false) {
|
||||
if ( !callback ) {
|
||||
return new Promise(resolve => {
|
||||
this.openResolves.push(resolve);
|
||||
});
|
||||
} else {
|
||||
this.openCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
_create_socket() {
|
||||
console.log('Connecting to socket:', this.url);
|
||||
this.socket = new _FWS(this.url);
|
||||
this.socket.onopen = (e) => {
|
||||
this.open = true;
|
||||
this.openResolves.forEach(r => r(this));
|
||||
this.openCallbacks.forEach(c => c(this));
|
||||
this.openResolves = [];
|
||||
this.openCallbacks = [];
|
||||
};
|
||||
this.socket.onmessage = this._on_message.bind(this);
|
||||
this.socket.onclose = (e) => {
|
||||
this.open = false;
|
||||
this.closeResolves.forEach(r => r(this));
|
||||
this.closeCallbacks.forEach(c => c(this));
|
||||
this.closeResolves = [];
|
||||
this.closeCallbacks = [];
|
||||
};
|
||||
}
|
||||
|
||||
_on_message(event) {
|
||||
const data = this.validate_message(event.data);
|
||||
if ( !data ) { return; }
|
||||
|
||||
if ( data.type === 'response' ) {
|
||||
if ( Object.keys(this.activeTransactions).includes(data.transaction_id) ) {
|
||||
const t = this.activeTransactions[data.transaction_id];
|
||||
t.receipt(data.data);
|
||||
if ( t.resolved ) { delete this.activeTransactions[t.id]; }
|
||||
} else { throw new Error('Response: transaction ID not found. It\'s possible that the transaction was already resolved.'); }
|
||||
} else if ( data.type === 'request' ) {
|
||||
const t = new FlitterSocketServerClientTransaction(data, this);
|
||||
this.activeTransactions[t.id] = t;
|
||||
|
||||
this._controller[t.endpoint](t, this.socket);
|
||||
if ( t.resolved ) { delete this.activeTransactions[t.id]; }
|
||||
}
|
||||
}
|
||||
|
||||
request(endpoint, data, handler) {
|
||||
const t = new FlitterSocketClientServerTransaction({data, endpoint}, this);
|
||||
t.handler(handler);
|
||||
|
||||
this.activeTransactions[t.id] = t;
|
||||
t.send();
|
||||
}
|
||||
|
||||
async asyncRequest(endpoint, data): Promise<[any, any, any]> {
|
||||
return new Promise((res, rej) => {
|
||||
try {
|
||||
this.request(endpoint, data, (...args: any) => {
|
||||
res(args);
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
rej(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validate_message(msg) {
|
||||
let fail = false;
|
||||
let validTid = true;
|
||||
let error = '';
|
||||
let code = 400;
|
||||
|
||||
// check if valid JSON
|
||||
if ( !this.is_json(msg) ) {
|
||||
fail = true;
|
||||
error = 'Incoming message must be valid FSP JSON object.';
|
||||
validTid = false;
|
||||
}
|
||||
|
||||
let data;
|
||||
if ( !fail ) { data = JSON.parse(msg); }
|
||||
|
||||
// check for required fields: transaction_id, type
|
||||
if ( !fail && !Object.keys(data).includes('transaction_id') ) {
|
||||
fail = true;
|
||||
error = 'Incoming message must include universally-unique transaction_id.';
|
||||
validTid = false;
|
||||
}
|
||||
if ( !fail && (!Object.keys(data).includes('type') || !(['request', 'response'].includes(data.type))) ) {
|
||||
fail = true;
|
||||
error = 'Incoming message must include valid type, which may be one of: request, response.';
|
||||
}
|
||||
|
||||
// if request, check for required fields: endpoint
|
||||
if ( !fail && data.type === 'request' && !Object.keys(data).includes('endpoint') ) {
|
||||
fail = true;
|
||||
error = 'Incoming request message must include a valid endpoint.';
|
||||
}
|
||||
|
||||
// if request, check if transaction_id is unique
|
||||
if ( !fail && data.type === 'request' && Object.keys(this.activeTransactions).includes(data.transaction_id) ) {
|
||||
fail = true;
|
||||
error = 'Incoming request message must have a universally-unique, NON-EXISTENT transaction_id.';
|
||||
validTid = false;
|
||||
}
|
||||
|
||||
// if request, check for valid endpoint
|
||||
if ( !fail && data.type === 'request' && !(typeof this._controller[data.endpoint] === 'function') ) {
|
||||
fail = true;
|
||||
error = 'The requested endpoint does not exist or is invalid.';
|
||||
code = 404;
|
||||
}
|
||||
|
||||
// if response, check if transaction_id exists
|
||||
if ( !fail && data.type === 'response' && !Object.keys(this.activeTransactions).includes(data.transaction_id)) {
|
||||
fail = true;
|
||||
error = 'The specified transaction_id does not exist. It\'s possible that this transaction has already resolved.';
|
||||
}
|
||||
|
||||
if ( fail ) {
|
||||
const sendData = {
|
||||
transaction_id: validTid ? data.transaction_id : 'unknown',
|
||||
message: error,
|
||||
status: code,
|
||||
type: 'response'
|
||||
};
|
||||
this.send_raw(sendData);
|
||||
} else { return data; }
|
||||
}
|
||||
|
||||
send_raw(data) {
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
uuid() {
|
||||
return uuid_v4();
|
||||
}
|
||||
|
||||
controller(set: any = false) {
|
||||
if ( !set ) { return this._controller; }
|
||||
this._controller = set;
|
||||
return this;
|
||||
}
|
||||
|
||||
is_json(str) {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user