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": {
|
"/link_api": {
|
||||||
"target": "http://localhost:8000",
|
"target": "http://localhost:8000",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
|
"ws": true,
|
||||||
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
"pathRewrite": {"^/link_api": ""}
|
"pathRewrite": {"^/link_api": ""}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,10 @@
|
|||||||
<wysiwyg-editor
|
<wysiwyg-editor
|
||||||
[contents]="contents"
|
[contents]="contents"
|
||||||
(contentsChanged)="onContentsChanged($event)"
|
(contentsChanged)="onContentsChanged($event)"
|
||||||
|
(selectionChanged)="onSelectionChanged($event)"
|
||||||
[readonly]="isReadonly"
|
[readonly]="isReadonly"
|
||||||
|
[editingUsers]="editorGroupUsers"
|
||||||
|
[editingUserSelections]="editingUserSelections"
|
||||||
|
[requestSelectionRefresh]="requestSelectionRefresh"
|
||||||
></wysiwyg-editor>
|
></wysiwyg-editor>
|
||||||
</div>
|
</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 {EditorNodeContract} from '../EditorNode.contract';
|
||||||
import {EditorService} from '../../../service/editor.service';
|
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({
|
@Component({
|
||||||
selector: 'editor-norm',
|
selector: 'editor-norm',
|
||||||
templateUrl: './norm.component.html',
|
templateUrl: './norm.component.html',
|
||||||
styleUrls: ['./norm.component.scss'],
|
styleUrls: ['./norm.component.scss'],
|
||||||
})
|
})
|
||||||
export class NormComponent extends EditorNodeContract implements OnInit {
|
export class NormComponent extends EditorNodeContract implements OnInit, OnDestroy {
|
||||||
@ViewChild('editable') editable;
|
@ViewChild('editable') editable;
|
||||||
@Input() nodeId: string;
|
@Input() nodeId: string;
|
||||||
@Input() editorUUID?: string;
|
@Input() editorUUID?: string;
|
||||||
@ -16,13 +20,23 @@ export class NormComponent extends EditorNodeContract implements OnInit {
|
|||||||
protected savedValue = 'Click to edit...';
|
protected savedValue = 'Click to edit...';
|
||||||
public contents = '';
|
public contents = '';
|
||||||
private dirtyOverride = false;
|
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(
|
constructor(
|
||||||
public editorService: EditorService,
|
public editorService: EditorService,
|
||||||
|
public readonly api: ApiService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.contents = this.initialValue;
|
this.contents = this.initialValue;
|
||||||
this.savedValue = this.initialValue;
|
this.savedValue = this.initialValue;
|
||||||
|
|
||||||
|
console.log('Norm editor component', this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isDark() {
|
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) {
|
onContentsChanged(contents: string) {
|
||||||
if ( this.contents !== contents ) {
|
if ( this.contents !== contents ) {
|
||||||
this.contents = contents;
|
this.contents = contents;
|
||||||
this.editorService.triggerSave();
|
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'"
|
<div [ngClass]="isDark() ? 'container dark' : 'container'"
|
||||||
(dblclick)="onFocusIn($event)">
|
(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">
|
<div class="toolbar-base" *ngIf="isFocused">
|
||||||
<button class="toolbar-button" title="Bold" (click)="documentCommand('bold')">
|
<button class="toolbar-button" title="Bold" (click)="documentCommand('bold')">
|
||||||
<i class="icon fa fa-bold"></i>
|
<i class="icon fa fa-bold"></i>
|
||||||
@ -65,16 +73,25 @@
|
|||||||
―
|
―
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="editable-container">
|
||||||
class="editable-base"
|
<div class="remote-cursors-container" *ngIf="editable && editingUserSelections && editingUserSelections.length">
|
||||||
[ngClass]="isFocused ? 'focused' : ''"
|
<div class="remote-cursor"
|
||||||
contenteditable
|
*ngFor="let sel of editingUserSelections"
|
||||||
appDomChange
|
[ngStyle]="{backgroundColor: sel.user_data.color, left: (sel.left || 0) + 'px', top: (sel.top || 0) + 'px'}"
|
||||||
*ngIf="isFocused"
|
[title]="sel.user_data.display"
|
||||||
[innerHTML]="contents"
|
></div>
|
||||||
#editable
|
</div>
|
||||||
(domChange)="onContentsChanged($event)"
|
<div
|
||||||
></div>
|
class="editable-base"
|
||||||
|
[ngClass]="isFocused ? 'focused' : ''"
|
||||||
|
contenteditable
|
||||||
|
appDomChange
|
||||||
|
*ngIf="isFocused"
|
||||||
|
[innerHTML]="contents"
|
||||||
|
#editable
|
||||||
|
(domChange)="onContentsChanged($event)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="editable-base"
|
class="editable-base"
|
||||||
*ngIf="!isFocused"
|
*ngIf="!isFocused"
|
||||||
|
@ -14,6 +14,28 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
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 {
|
.toolbar-button {
|
||||||
height: calc(100% - 6px);
|
height: calc(100% - 6px);
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
@ -38,6 +60,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-cursors-container {
|
||||||
|
.remote-cursor {
|
||||||
|
min-height: 20px;
|
||||||
|
width: 3px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.container.dark {
|
.container.dark {
|
||||||
.editable-base.focused {
|
.editable-base.focused {
|
||||||
background: #404040 !important;
|
background: #404040 !important;
|
||||||
|
@ -1,4 +1,17 @@
|
|||||||
import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild} from '@angular/core';
|
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({
|
@Component({
|
||||||
selector: 'wysiwyg-editor',
|
selector: 'wysiwyg-editor',
|
||||||
@ -8,6 +21,7 @@ import {Component, EventEmitter, HostListener, Input, OnInit, Output, ViewChild}
|
|||||||
export class WysiwygComponent implements OnInit {
|
export class WysiwygComponent implements OnInit {
|
||||||
@ViewChild('editable') editable;
|
@ViewChild('editable') editable;
|
||||||
@Input() readonly = false;
|
@Input() readonly = false;
|
||||||
|
@Input() requestSelectionRefresh?: () => any;
|
||||||
@Input() set contents(val: string) {
|
@Input() set contents(val: string) {
|
||||||
if ( this.isFocused ) {
|
if ( this.isFocused ) {
|
||||||
if ( this.editingContents !== val ) {
|
if ( this.editingContents !== val ) {
|
||||||
@ -24,7 +38,20 @@ export class WysiwygComponent implements OnInit {
|
|||||||
return this.currentContents;
|
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() contentsChanged: EventEmitter<string> = new EventEmitter<string>();
|
||||||
|
@Output() selectionChanged: EventEmitter<{path: string, offset: number}> = new EventEmitter<{path: string; offset: number}>();
|
||||||
|
|
||||||
public currentContents = '';
|
public currentContents = '';
|
||||||
protected editingContents = '';
|
protected editingContents = '';
|
||||||
@ -50,15 +77,109 @@ export class WysiwygComponent implements OnInit {
|
|||||||
.replace(/(https?:\/\/[^\s]+)/g, (val) => `<a href="${val}" target="_blank">${val}</a>`);
|
.replace(/(https?:\/\/[^\s]+)/g, (val) => `<a href="${val}" target="_blank">${val}</a>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected sanitizer: DomSanitizer,
|
||||||
|
) { }
|
||||||
|
|
||||||
public isDark() {
|
public isDark() {
|
||||||
return document.body.classList.contains('dark');
|
return document.body.classList.contains('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() { }
|
ngOnInit() {
|
||||||
|
debug('Initializing WYSIWYG', this);
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', event => {
|
||||||
|
this.selectionChanged.emit(this.getSerializedSelection());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onFocusIn(event: MouseEvent) {
|
onFocusIn(event: MouseEvent) {
|
||||||
this.isFocused = !this.readonly;
|
this.isFocused = !this.readonly;
|
||||||
this.editingContents = this.currentContents;
|
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'])
|
@HostListener('document:keyup.escape', ['$event'])
|
||||||
@ -133,4 +254,13 @@ export class WysiwygComponent implements OnInit {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.documentCommand('redo');
|
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