Skip to content

Commit 90470b7

Browse files
authored
OT Frontend Changes - Stage 1 (#298)
* Added stage 1 of OT frontend changes - operation capture * code cleanup + refactor * trying to resolve broken pipeline * review changes
1 parent 5d57a72 commit 90470b7

File tree

16 files changed

+893
-192
lines changed

16 files changed

+893
-192
lines changed

frontend/package-lock.json

Lines changed: 343 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"@mui/material": "5.8.5",
1212
"@mui/styles": "5.8.4",
1313
"@reduxjs/toolkit": "1.8.4",
14+
"@types/socket.io": "^3.0.2",
15+
"@types/socket.io-client": "^3.0.0",
1416
"react": "17.0.2",
1517
"react-dom": "17.0.2",
1618
"react-icons": "4.3.1",
@@ -20,6 +22,8 @@
2022
"redux-saga": "1.1.3",
2123
"slate": "0.78.0",
2224
"slate-react": "0.79.0",
25+
"socket.io": "^4.5.3",
26+
"socket.io-client": "^4.5.3",
2327
"styled-components": "5.3.5",
2428
"uuid": "8.3.2",
2529
"web-vitals": "1.1.2"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# CMS OT Client
2+
3+
TODO: there really needs to be a better way of doing this, currently this code is copied directly from the backend folder. In the future there should maybe be some shared folder between frontend and backend JUST for stuff like API clients?
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Client server implementation
3+
*/
4+
5+
import { io, Socket } from "socket.io-client";
6+
import { CMSOperation } from "./operation";
7+
import { OperationQueue } from "./operationQueue";
8+
import { bind } from "./util";
9+
10+
const ACK_TIMEOUT_DURATION = 10_000;
11+
12+
/*
13+
The Client-Server protocol
14+
- The general outline of our client-server protocol is as follows:
15+
- Client wants to send an operation (it applies it locally)
16+
- If there are any operations in the op buffer it pushes it to the end
17+
- If there aren't it sends it directly to the server
18+
19+
- The client then awaits for an acknowledgment
20+
- While it waits of an acknowledgement it queues everything in the buffer
21+
- All incoming operations from the server are transformed against buffer operations (As they haven't been applied yet)
22+
- When at acknowledgement is received the client then sends the next queued operation to the server
23+
*/
24+
25+
export default class Client {
26+
// TODO: Handle destruction / closing of the websocket
27+
constructor(opCallback: (op: CMSOperation) => void) {
28+
this.socket = io(`ws://localhost:8080/edit?document=${document}`);
29+
30+
this.socket.on("connect", this.handleConnection);
31+
this.socket.on("ack", this.handleAck);
32+
this.socket.on("op", this.handleOperation(opCallback));
33+
}
34+
35+
/**
36+
* Handles an incoming acknowledgement operation
37+
*/
38+
private handleAck = () => {
39+
clearTimeout(this.timeoutID);
40+
this.pendingAcknowledgement = false;
41+
42+
// dequeue the current operation and send a new one if required
43+
this.queuedOperations.dequeueOperation();
44+
bind((op) => this.sendToServer(op), this.queuedOperations.peekHead());
45+
};
46+
47+
/**
48+
* Handles an incoming operation from the server
49+
*/
50+
private handleOperation =
51+
(opCallback: (op: CMSOperation) => void) => (operation: CMSOperation) => {
52+
const transformedOp =
53+
this.queuedOperations.applyAndTransformIncomingOperation(operation);
54+
opCallback(transformedOp);
55+
56+
this.appliedOperations += 1;
57+
};
58+
59+
/**
60+
* Handles the even when the connection opens
61+
*/
62+
private handleConnection = () => {
63+
console.log(`Socket ${this.socket.id} connected: ${this.socket.connected}`);
64+
};
65+
66+
/**
67+
* Send an operation from client to centralised server through websocket
68+
*
69+
* @param operation the operation the client wants to send
70+
*/
71+
public pushOperation = (operation: CMSOperation) => {
72+
// Note that if there aren't any pending acknowledgements then the operation queue will be empty
73+
this.queuedOperations.enqueueOperation(operation);
74+
75+
if (!this.pendingAcknowledgement) {
76+
this.sendToServer(operation);
77+
}
78+
};
79+
80+
/**
81+
* Pushes an operation to the server
82+
*/
83+
private sendToServer = (operation: CMSOperation) => {
84+
this.pendingAcknowledgement = true;
85+
86+
this.socket.send(
87+
JSON.stringify({ operation, appliedOperations: this.appliedOperations })
88+
);
89+
this.timeoutID = setTimeout(
90+
() => {
91+
throw Error(`Did not receive ACK after ${ACK_TIMEOUT_DURATION} ms!`);
92+
},
93+
ACK_TIMEOUT_DURATION,
94+
"finish"
95+
);
96+
};
97+
98+
private socket: Socket;
99+
100+
private queuedOperations: OperationQueue = new OperationQueue();
101+
private pendingAcknowledgement = false;
102+
private appliedOperations = 0;
103+
104+
// distinct types between node and the browser confuses typescript
105+
private timeoutID: any = 0;
106+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Represents atomic operations that can be applied to a piece of data of a specific type
2+
// TODO: in the future update object operation to strictly contain CMS operation data
3+
type stringOperation = { rangeStart: number; rangeEnd: number, newValue: string };
4+
type integerOperation = { newValue: number };
5+
type booleanOperation = { newValue: boolean };
6+
// eslint-disable-next-line @typescript-eslint/ban-types
7+
type objectOperation = { newValue: object };
8+
// eslint-disable-next-line @typescript-eslint/ban-types
9+
type arrayOperation = { newValue: object };
10+
type noop = Record<string, never>;
11+
12+
// atomicOperation is a single operation that can be applied in our system
13+
type atomicOperation =
14+
| { "$type": "stringOperation", stringOperation: stringOperation }
15+
| { "$type": "integerOperation", integerOperation: integerOperation }
16+
| { "$type": "booleanOperation", booleanOperation: booleanOperation }
17+
| { "$type": "objectOperation", objectOperation: objectOperation }
18+
| { "$type": "arrayOperation", arrayOperation: arrayOperation }
19+
| { "$type": "noop", noop: noop}
20+
21+
// operation is the atomic operation that is sent between clients and servers
22+
export type CMSOperation = {
23+
Path: number[],
24+
OperationType: "insert" | "delete",
25+
26+
IsNoOp: boolean
27+
Operation: atomicOperation
28+
}
29+
30+
export const noop: CMSOperation = {
31+
Path: [],
32+
OperationType: "insert",
33+
IsNoOp: true,
34+
Operation: {
35+
"$type": "noop",
36+
noop: {}
37+
}
38+
};
39+
40+
// Actual OT transformation functions
41+
export const transform = (
42+
a: CMSOperation,
43+
b: CMSOperation
44+
): [CMSOperation, CMSOperation] => {
45+
const transformedPaths = transformPaths(a, b);
46+
[a.Path, b.Path] = transformedPaths;
47+
48+
return [normalise(a), normalise(b)];
49+
};
50+
51+
/**
52+
* Takes in two operations and transforms them accordingly, note that it only
53+
* returns the updated paths
54+
*/
55+
const transformPaths = (a: CMSOperation, b: CMSOperation): [number[], number[]] => {
56+
const tp = transformationPoint(a.Path, b.Path);
57+
if (!effectIndependent(a.Path, b.Path, tp)) {
58+
switch (true) {
59+
case a.OperationType === "insert" && b.OperationType === "insert":
60+
return transformInserts(a.Path, b.Path, tp);
61+
case a.OperationType === "delete" && b.OperationType === "delete":
62+
return transformDeletes(a.Path, b.Path, tp);
63+
case a.OperationType === "insert" && b.OperationType === "delete":
64+
return transformInsertDelete(a.Path, b.Path, tp);
65+
default: {
66+
const result = transformInsertDelete(b.Path, a.Path, tp);
67+
result.reverse();
68+
return result;
69+
}
70+
}
71+
}
72+
73+
return [a.Path, b.Path];
74+
};
75+
76+
/**
77+
* Takes 2 paths and their transformation point and transforms them as if they
78+
* were insertion functions
79+
*/
80+
const transformInserts = (
81+
a: number[],
82+
b: number[],
83+
tp: number
84+
): [number[], number[]] => {
85+
switch (true) {
86+
case a[tp] > b[tp]:
87+
return [update(a, tp, 1), b];
88+
case a[tp] < b[tp]:
89+
return [a, update(b, tp, 1)];
90+
default:
91+
return a.length > b.length
92+
? [update(a, tp, 1), b]
93+
: (a.length < b.length
94+
? [a, update(b, tp, 1)]
95+
: [a, b]);
96+
}
97+
};
98+
99+
/**
100+
* Takes 2 paths and transforms them as if they were deletion operations
101+
*/
102+
const transformDeletes = (
103+
a: number[],
104+
b: number[],
105+
tp: number
106+
): [number[], number[]] => {
107+
switch (true) {
108+
case a[tp] > b[tp]:
109+
return [update(a, tp, -1), b];
110+
case a[tp] < b[tp]:
111+
return [a, update(b, tp, -1)];
112+
default:
113+
return a.length > b.length
114+
? [[], b]
115+
: (a.length < b.length
116+
? [a, []]
117+
: [[], []]);
118+
}
119+
};
120+
121+
/**
122+
* Takes an insertion operation and a deletion operation and transforms them
123+
*/
124+
const transformInsertDelete = (
125+
insertOp: number[],
126+
deleteOp: number[],
127+
tp: number
128+
): [number[], number[]] => {
129+
switch (true) {
130+
case insertOp[tp] > deleteOp[tp]:
131+
return [update(insertOp, tp, -1), deleteOp];
132+
case insertOp[tp] < deleteOp[tp]:
133+
return [insertOp, update(deleteOp, tp, 1)];
134+
default:
135+
return insertOp.length > deleteOp.length
136+
? [[], deleteOp]
137+
: [insertOp, update(deleteOp, tp, 1)];
138+
}
139+
};
140+
141+
/**
142+
* Updates a specific index in a path
143+
*/
144+
const update = (pos: number[], toChange: number, change: number) => {
145+
pos[toChange] += change;
146+
return pos;
147+
};
148+
149+
/**
150+
* Takes in two paths and computes their transformation point
151+
*/
152+
const transformationPoint = (a: number[], b: number[]): number =>
153+
[...Array(Math.min(a.length, b.length)).keys()].find(
154+
(i) => a[i] != b[i]
155+
) ?? Math.min(a.length, b.length);
156+
157+
/**
158+
* Takes two paths and determines if their effect is independent or not
159+
*/
160+
const effectIndependent = (a: number[], b: number[], tp: number): boolean =>
161+
(a.length > tp + 1 && b.length > tp + 1) ||
162+
(a[tp] > b[tp] && a.length < b.length) ||
163+
(a[tp] < b[tp] && a.length > b.length);
164+
165+
/**
166+
* Normalise turns an empty operation into a noop
167+
*/
168+
const normalise = (a: CMSOperation): CMSOperation => (a.Path.length === 0 ? noop : a);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { CMSOperation, transform } from "./operation";
2+
3+
/**
4+
* OperationQueue is a simple data structure of the maintenance of outgoing
5+
* operations, new operations are pushed to this queue and when and incoming
6+
* operation from the server applies that operation is transformed against all
7+
* elements of this queue
8+
*/
9+
export class OperationQueue {
10+
11+
/**
12+
* Push an operation to the end of the operation queue
13+
*
14+
* @param operation - the new operation to add
15+
* @returns the new length of the queue
16+
*/
17+
public enqueueOperation = (operation: CMSOperation): number =>
18+
this.operationQueue.push(operation);
19+
20+
/**
21+
* Takes an incoming operation from the server and applies it to all
22+
* elements of the queue
23+
*
24+
* @param serverOp - the incoming operation from the server
25+
* @returns serverOp transformed against all operations in the operation queue
26+
*/
27+
public applyAndTransformIncomingOperation = (
28+
serverOp: CMSOperation
29+
): CMSOperation => {
30+
const { newQueue, newOp } = this.operationQueue.reduce(
31+
(prevSet, op) => {
32+
const newOp = transform(op, prevSet.newOp);
33+
return { newQueue: prevSet.newQueue.concat(newOp), newOp: newOp[1] };
34+
},
35+
{ newQueue: [] as CMSOperation[], newOp: serverOp }
36+
);
37+
38+
this.operationQueue = newQueue;
39+
return newOp;
40+
};
41+
42+
/**
43+
* @returns if are any operations queued
44+
*/
45+
public isEmpty = (): boolean => this.operationQueue.length === 0;
46+
47+
/**
48+
* @returns the operation at the head of the operation queue and removes it
49+
*/
50+
public dequeueOperation = (): CMSOperation | undefined =>
51+
this.operationQueue.shift();
52+
53+
/**
54+
* @returns the operation at the head of the operation queue
55+
*/
56+
public peekHead = (): CMSOperation | undefined => this.operationQueue[0];
57+
58+
// operationQueue is our internal operation queue
59+
private operationQueue = [] as CMSOperation[];
60+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Bind operation in functional programming
3+
*
4+
* @param f - the function to apply on the value
5+
* @param y - the value
6+
* @returns the return value of the function with y passed in if y is not undefined else undefined
7+
*/
8+
export const bind = <T, V>(f: (x: T) => V, y: T | undefined): V | undefined =>
9+
y !== undefined ? f(y) : undefined;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// TODO: remove this and replace with API client once thats complete, see:
2+
// https://github.com/csesoc/website/pull/238
3+
export const publishDocument = (documentId: string) => {
4+
fetch("/api/filesystem/publish-document", {
5+
method: "POST",
6+
headers: {
7+
"Content-Type": "application/x-www-form-urlencoded",
8+
},
9+
body: new URLSearchParams({
10+
DocumentID: `${documentId}`,
11+
}),
12+
});
13+
}

0 commit comments

Comments
 (0)