Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@bangle.dev/collab-client

Package Overview
Dependencies
Maintainers
1
Versions
112
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@bangle.dev/collab-client - npm Package Compare versions

Comparing version 0.30.0-alpha.4 to 0.30.0-alpha.5

src/commands.ts

70

__tests__/collab-client.test.ts

@@ -19,3 +19,4 @@ /**

import { plugins, pullChanges } from '../src/collab-extension';
import { plugins } from '../src/collab-extension';
import { onUpstreamChanges } from '../src/commands';

@@ -56,4 +57,4 @@ waitForExpect.defaults.timeout = 500;

docChangeEmitter.on('doc_changed', () => {
pullChanges()(editor.view.state, editor.view.dispatch);
docChangeEmitter.on('doc_changed', ({ version }: { version: number }) => {
onUpstreamChanges(version)(editor.view.state, editor.view.dispatch);
});

@@ -68,5 +69,2 @@

typeText,
pullFromServer() {
docChangeEmitter.emit('doc_changed', {});
},
};

@@ -109,6 +107,9 @@ };

},
applyCollabState: () => {
applyCollabState: (docName, newCollabState) => {
queueMicrotask(() => {
if (emitDocChangeEvent) {
docChangeEmitter.emit('doc_changed', {});
docChangeEmitter.emit('doc_changed', {
docName,
version: newCollabState.version,
});
}

@@ -384,3 +385,2 @@ });

console.log('TYPING HELLO');
client2.typeText('hello', 6);

@@ -453,2 +453,3 @@ await sleep();

await sleep(500);
await waitForExpect(async () => {

@@ -655,27 +656,31 @@ expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual(

const fakeClientId = 'fakeClientId';
await manager.handleRequest({
type: CollabRequestType.PushEvents,
payload: {
clientID: fakeClientId,
version: manager.getCollabState(docName)?.version!,
managerId: manager.managerId,
steps: [
{
stepType: 'replace',
from: 1,
to: 1,
slice: {
content: [
{
type: 'text',
text: 'very ',
expect(
(
await manager.handleRequest({
type: CollabRequestType.PushEvents,
payload: {
clientID: fakeClientId,
version: manager.getCollabState(docName)?.version!,
managerId: manager.managerId,
steps: [
{
stepType: 'replace',
from: 1,
to: 1,
slice: {
content: [
{
type: 'text',
text: 'very ',
},
],
},
],
},
},
],
docName,
userId: 'test-' + fakeClientId,
},
],
docName,
userId: 'test-' + fakeClientId,
},
});
})
).ok,
).toBe(true);

@@ -690,3 +695,2 @@ await waitForExpect(async () => {

server.allowDocChangeEvent();
client1.typeText('wow ');

@@ -693,0 +697,0 @@ expect(client1.debugString()).toEqual(`doc(paragraph("wow hello world!"))`);

@@ -1,16 +0,78 @@

import { CollabManager } from '@bangle.dev/collab-server';
import { EditorState, Plugin, Command } from '@bangle.dev/pm';
import { CollabFail, CollabManager } from '@bangle.dev/collab-server';
import { Node, TextSelection, Command } from '@bangle.dev/pm';
import * as prosemirror_state from 'prosemirror-state';
interface CollabSettings {
serverVersion: undefined | number;
}
declare type ValidStates = FatalErrorState | InitDocState | InitErrorState | InitState | PullState | PushPullErrorState | PushState | ReadyState;
declare enum CollabStateName {
FatalError = "FATAL_ERROR_STATE",
Init = "INIT_STATE",
InitDoc = "INIT_DOC_STATE",
InitError = "INIT_ERROR_STATE",
Pull = "PULL_STATE",
Push = "PUSH_STATE",
PushPullError = "PUSH_PULL_ERROR_STATE",
Ready = "READY_STATE"
}
interface FatalErrorState {
name: CollabStateName.FatalError;
state: {
message: string;
};
}
interface InitState {
name: CollabStateName.Init;
}
interface InitDocState {
name: CollabStateName.InitDoc;
state: {
initialDoc: Node;
initialVersion: number;
initialSelection?: TextSelection;
managerId: string;
};
}
interface InitErrorState {
name: CollabStateName.InitError;
state: {
failure: CollabFail;
};
}
interface ReadyState {
name: CollabStateName.Ready;
state: InitDocState['state'];
}
interface PushState {
name: CollabStateName.Push;
state: InitDocState['state'];
}
interface PullState {
name: CollabStateName.Pull;
state: InitDocState['state'];
}
interface PushPullErrorState {
name: CollabStateName.PushPullError;
state: {
failure: CollabFail;
initDocState: InitDocState['state'];
};
}
interface CollabPluginContext {
readonly restartCount: number;
readonly debugInfo: string | undefined;
}
interface CollabPluginState {
context: CollabPluginContext;
collabState: ValidStates;
}
declare function onUpstreamChanges(version: number): Command;
declare const plugins: typeof pluginsFactory;
declare const commands: {
pullChanges: typeof pullChanges;
onUpstreamChanges: typeof onUpstreamChanges;
};
declare const RECOVERY_BACK_OFF = 50;
interface CollabSettings {
docName: string;
clientID: string;
userId: string;
ready: boolean;
}
declare const getCollabSettings: (state: EditorState) => CollabSettings;
declare function pluginsFactory({ clientID, docName, sendManagerRequest, retryWaitTime, }: {

@@ -21,9 +83,3 @@ clientID: string;

retryWaitTime?: number;
}): () => (Plugin<any> | Plugin<null> | Plugin<{
docName: string;
clientID: string;
userId: string;
ready: boolean;
}>)[];
declare function pullChanges(): Command;
}): (prosemirror_state.Plugin<any> | (prosemirror_state.Plugin<CollabPluginState> | prosemirror_state.Plugin<CollabSettings>)[])[];

@@ -33,4 +89,2 @@ declare const collabExtension_d_plugins: typeof plugins;

declare const collabExtension_d_RECOVERY_BACK_OFF: typeof RECOVERY_BACK_OFF;
declare const collabExtension_d_getCollabSettings: typeof getCollabSettings;
declare const collabExtension_d_pullChanges: typeof pullChanges;
declare namespace collabExtension_d {

@@ -41,4 +95,2 @@ export {

collabExtension_d_RECOVERY_BACK_OFF as RECOVERY_BACK_OFF,
collabExtension_d_getCollabSettings as getCollabSettings,
collabExtension_d_pullChanges as pullChanges,
};

@@ -45,0 +97,0 @@ }

import { getVersion, receiveTransaction, sendableSteps, collab } from 'prosemirror-collab';
import { PluginKey, Step, TextSelection, Selection, Node, Plugin } from '@bangle.dev/pm';
import { isTestEnv, abortableSetTimeout, uuid } from '@bangle.dev/utils';
import { CollabRequestType, CollabFail } from '@bangle.dev/collab-server';
import { PluginKey, Step, TextSelection, Selection, Node, Plugin } from '@bangle.dev/pm';
const collabClientKey = new PluginKey('bangle.dev/collab-client');
const collabSettingsKey = new PluginKey('bangle/collabSettingsKey');
var EventType;

@@ -17,38 +19,81 @@ (function (EventType) {

})(EventType || (EventType = {}));
var StateName;
(function (StateName) {
StateName["FatalError"] = "FATAL_ERROR_STATE";
StateName["Init"] = "INIT_STATE";
StateName["InitDoc"] = "INIT_DOC_STATE";
StateName["InitError"] = "INIT_ERROR_STATE";
StateName["Pull"] = "PULL_STATE";
StateName["Push"] = "PUSH_STATE";
StateName["PushPullError"] = "PUSH_PULL_ERROR_STATE";
StateName["Ready"] = "READY_STATE";
})(StateName || (StateName = {}));
var CollabStateName;
(function (CollabStateName) {
CollabStateName["FatalError"] = "FATAL_ERROR_STATE";
CollabStateName["Init"] = "INIT_STATE";
CollabStateName["InitDoc"] = "INIT_DOC_STATE";
CollabStateName["InitError"] = "INIT_ERROR_STATE";
CollabStateName["Pull"] = "PULL_STATE";
CollabStateName["Push"] = "PUSH_STATE";
CollabStateName["PushPullError"] = "PUSH_PULL_ERROR_STATE";
CollabStateName["Ready"] = "READY_STATE";
})(CollabStateName || (CollabStateName = {}));
const collabSettingsKey = new PluginKey('bangle/collabSettingsKey');
const collabPluginKey = new PluginKey('bangle/collabPluginKey');
function replaceDocument(state, serializedDoc, version) {
const { schema, tr } = state;
const content =
// TODO remove serializedDoc
serializedDoc instanceof Node
? serializedDoc.content
: (serializedDoc.content || []).map((child) => schema.nodeFromJSON(child));
const hasContent = Array.isArray(content)
? content.length > 0
: Boolean(content);
if (!hasContent) {
return tr;
}
tr.setMeta('addToHistory', false);
tr.replaceWith(0, state.doc.nodeSize - 2, content);
tr.setSelection(Selection.atStart(tr.doc));
if (typeof version !== undefined) {
const collabState = { version, unconfirmed: [] };
tr.setMeta('collab$', collabState);
}
return tr;
// In the following states, the user is not allowed to edit the document.
const NoEditStates = [
CollabStateName.Init,
CollabStateName.InitDoc,
CollabStateName.FatalError,
];
function dispatchCollabPluginEvent(data) {
return (state, dispatch) => {
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta(collabClientKey, data));
return true;
};
}
function onUpstreamChanges(version) {
return (state, dispatch) => {
const pluginState = collabSettingsKey.getState(state);
if (!pluginState) {
return false;
}
if (pluginState.serverVersion !== version) {
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta(collabSettingsKey, { serverVersion: version }));
}
return false;
};
}
function onLocalChanges() {
return (state, dispatch) => {
const pluginState = collabClientKey.getState(state);
if (!pluginState) {
return false;
}
if (isCollabStateReady()(state)) {
dispatchCollabPluginEvent({
context: {
debugInfo: 'onLocalChanges',
},
collabEvent: {
type: EventType.Push,
},
})(state, dispatch);
return true;
}
return false;
};
}
function isOutdatedVersion() {
return (state) => {
var _a;
const serverVersion = (_a = collabSettingsKey.getState(state)) === null || _a === void 0 ? void 0 : _a.serverVersion;
return (typeof serverVersion === 'number' && getVersion(state) < serverVersion);
};
}
function isCollabStateReady() {
return (state) => {
var _a;
return (((_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.collabState.name) ===
CollabStateName.Ready);
};
}
// In these states the document is frozen and no edits and _almost_ no transactions are allowed
function isNoEditState() {
return (state) => {
var _a;
const stateName = (_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.collabState.name;
return stateName ? NoEditStates.includes(stateName) : false;
};
}
function applySteps(view, payload, logger) {

@@ -68,3 +113,3 @@ if (view.isDestroyed) {

.setMeta('addToHistory', false)
.setMeta('bangle/isRemote', true);
.setMeta('bangle.dev/isRemote', true);
const newState = view.state.apply(tr);

@@ -75,8 +120,2 @@ view.updateState(newState);

}
// Prevent any changes in the document from happening
function freezeDoc(view) {
view.state.apply(view.state.tr
.setMeta('bangle/isRemote', true)
.setMeta('bangle/allowUpdatingEditorState', false));
}
function applyDoc(view, doc, version, oldSelection) {

@@ -100,8 +139,27 @@ if (view.isDestroyed) {

}
const newState = view.state.apply(tr
.setMeta('bangle/isRemote', true)
.setMeta('bangle/allowUpdatingEditorState', true));
const newState = view.state.apply(tr.setMeta('bangle.dev/isRemote', true));
view.updateState(newState);
view.dispatch(view.state.tr.setMeta(collabSettingsKey, { ready: true }));
}
function replaceDocument(state, serializedDoc, version) {
const { schema, tr } = state;
const content =
// TODO remove serializedDoc
serializedDoc instanceof Node
? serializedDoc.content
: (serializedDoc.content || []).map((child) => schema.nodeFromJSON(child));
const hasContent = Array.isArray(content)
? content.length > 0
: Boolean(content);
if (!hasContent) {
return tr;
}
tr.setMeta('addToHistory', false);
tr.replaceWith(0, state.doc.nodeSize - 2, content);
tr.setSelection(Selection.atStart(tr.doc));
if (typeof version !== undefined) {
const collabState = { version, unconfirmed: [] };
tr.setMeta('collab$', collabState);
}
return tr;
}

@@ -112,56 +170,137 @@ const LOG = true;

: () => { };
const MAX_RESTARTS = 100;
function collabClient(param) {
const logger = (...args) => log(`${param.clientID}:version=${getVersion(context.view.state)}:`, ...args);
const context = {
...param,
pendingPush: false,
pendingUpstreamChange: false,
restartCount: 0,
function collabClientPlugin(clientInfo) {
const logger = (state) => (...args) => {
var _a, _b;
return log(`${clientInfo.clientID}:version=${getVersion(state)}:${(_b = (_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.context.debugInfo) !== null && _b !== void 0 ? _b : ''}`, ...args);
};
let gState = { name: StateName.Init };
let controller = new AbortController();
const dispatchEvent = (event, debugString) => {
if (event.type === EventType.Restart) {
context.restartCount++;
}
if (context.restartCount > MAX_RESTARTS) {
freezeDoc(context.view);
throw new Error('Too many restarts');
}
const state = applyState(gState, event, logger);
// only update state if it changed, we donot support self transitions
if (gState.name !== state.name) {
controller.abort();
controller = new AbortController();
logger(debugString ? `from=${debugString}:` : '', `event ${event.type} changed state from ${gState.name} to ${state.name}`);
gState = state;
runActions(context, state, controller.signal, dispatchEvent, logger);
}
else {
logger(debugString, `event ${event.type} ignored`);
}
};
runActions(context, gState, controller.signal, dispatchEvent, logger);
return {
onUpstreamChange() {
if (gState.name === StateName.Ready) {
dispatchEvent({ type: EventType.Pull }, 'onUpstreamChange');
}
else {
context.pendingUpstreamChange = true;
}
},
onLocalEdits() {
if (gState.name === StateName.Ready) {
dispatchEvent({ type: EventType.Push }, 'onLocalEdits');
}
else {
context.pendingPush = true;
}
},
async destroy() {
controller.abort();
},
};
return [
new Plugin({
key: collabClientKey,
filterTransaction(tr, state) {
// Do not block collab plugins' transactions
if (tr.getMeta(collabClientKey) ||
tr.getMeta(collabSettingsKey) ||
tr.getMeta('bangle.dev/isRemote')) {
return true;
}
// prevent any other tr until state is in one of the no-edit state
if (tr.docChanged && isNoEditState()(state)) {
logger(state)('skipping transaction');
return false;
}
return true;
},
state: {
init() {
return {
context: {
restartCount: 0,
debugInfo: undefined,
},
collabState: { name: CollabStateName.Init },
};
},
apply(tr, value, oldState, newState) {
const meta = tr.getMeta(collabClientKey);
if (meta === undefined) {
return value;
}
let result = {
...value,
context: {
...value.context,
// unset debugInfo everytime
debugInfo: undefined,
},
};
if (meta.collabEvent) {
const newPluginState = applyState(value.collabState, meta.collabEvent, logger(newState));
if (newPluginState.name !== value.collabState.name) {
result.collabState = newPluginState;
}
else {
logger(newState)('applyState', `debugInfo=${result.context.debugInfo}`, `event ${meta.collabEvent.type} ignored`);
}
}
if (meta.context) {
result.context = { ...result.context, ...meta.context };
}
logger(newState)('apply state, newStateName=', result.collabState.name, `debugInfo=${result.context.debugInfo}`, 'oldStateName=', value.collabState.name);
return result;
},
},
view(view) {
let controller = new AbortController();
const pluginState = collabClientKey.getState(view.state);
if (pluginState) {
runActions(clientInfo, pluginState.collabState, pluginState.context, view, controller.signal, logger(view.state));
}
return {
destroy() {
controller.abort();
},
update(view, prevState) {
var _a;
const pluginState = collabClientKey.getState(view.state);
// ignore running actions if collab state didn't change
if ((pluginState === null || pluginState === void 0 ? void 0 : pluginState.collabState) ===
((_a = collabClientKey.getState(prevState)) === null || _a === void 0 ? void 0 : _a.collabState)) {
return;
}
if (pluginState) {
controller.abort();
controller = new AbortController();
runActions(clientInfo, pluginState.collabState, pluginState.context, view, controller.signal, logger(view.state));
}
},
};
},
}),
new Plugin({
key: collabSettingsKey,
state: {
init: (_, _state) => {
return {
serverVersion: undefined,
};
},
apply: (tr, value, oldState, newState) => {
const meta = tr.getMeta(collabSettingsKey);
if (meta) {
logger(newState)('collabSettingsKey received tr', meta);
return {
...value,
...meta,
};
}
return value;
},
},
view(view) {
// // If there are sendable steps to send to server
if (sendableSteps(view.state)) {
onLocalChanges()(view.state, view.dispatch);
}
return {
update(view) {
if (isOutdatedVersion()(view.state) &&
isCollabStateReady()(view.state)) {
dispatchCollabPluginEvent({
context: {
debugInfo: 'collabSettingsKey(outdated-local-version)',
},
collabEvent: {
type: EventType.Pull,
},
})(view.state, view.dispatch);
}
// // If there are sendable steps to send to server
if (sendableSteps(view.state)) {
onLocalChanges()(view.state, view.dispatch);
}
},
};
},
}),
];
}

@@ -176,9 +315,9 @@ // Must be kept pure -- no side effects

// FatalError is terminal and should not allow at state transitions
case StateName.FatalError: {
case CollabStateName.FatalError: {
logIgnoreEvent(state, event);
return state;
}
case StateName.InitDoc: {
case CollabStateName.InitDoc: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
}

@@ -188,9 +327,9 @@ logIgnoreEvent(state, event);

}
case StateName.InitError: {
case CollabStateName.InitError: {
if (event.type === EventType.Restart) {
return { name: StateName.Init };
return { name: CollabStateName.Init };
}
else if (event.type === EventType.FatalError) {
return {
name: StateName.FatalError,
name: CollabStateName.FatalError,
state: { message: event.payload.message },

@@ -202,7 +341,7 @@ };

}
case StateName.Init: {
case CollabStateName.Init: {
if (event.type === EventType.InitDoc) {
const { payload } = event;
return {
name: StateName.InitDoc,
name: CollabStateName.InitDoc,
state: {

@@ -218,3 +357,3 @@ initialDoc: payload.doc,

return {
name: StateName.InitError,
name: CollabStateName.InitError,
state: { failure: event.payload.failure },

@@ -226,9 +365,9 @@ };

}
case StateName.Pull: {
case CollabStateName.Pull: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
}
else if (event.type === EventType.PushPullError) {
return {
name: StateName.PushPullError,
name: CollabStateName.PushPullError,
state: {

@@ -243,12 +382,12 @@ failure: event.payload.failure,

}
case StateName.PushPullError: {
case CollabStateName.PushPullError: {
if (event.type === EventType.Restart) {
return { name: StateName.Init };
return { name: CollabStateName.Init };
}
else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state.initDocState };
return { name: CollabStateName.Pull, state: state.state.initDocState };
}
else if (event.type === EventType.FatalError) {
return {
name: StateName.FatalError,
name: CollabStateName.FatalError,
state: { message: event.payload.message },

@@ -260,12 +399,12 @@ };

}
case StateName.Push: {
case CollabStateName.Push: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
}
else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state };
return { name: CollabStateName.Pull, state: state.state };
}
else if (event.type === EventType.PushPullError) {
return {
name: StateName.PushPullError,
name: CollabStateName.PushPullError,
state: {

@@ -280,8 +419,8 @@ failure: event.payload.failure,

}
case StateName.Ready: {
case CollabStateName.Ready: {
if (event.type === EventType.Push) {
return { name: StateName.Push, state: state.state };
return { name: CollabStateName.Push, state: state.state };
}
else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state };
return { name: CollabStateName.Pull, state: state.state };
}

@@ -297,52 +436,76 @@ logIgnoreEvent(state, event);

// code that needs to run when state changes
async function runActions(context, state, signal, dispatch, logger) {
async function runActions(clientInfo, collabState, context, view, signal, logger) {
if (signal.aborted) {
return;
}
const stateName = state.name;
const { view } = context;
logger(`running action for ${stateName}`);
const stateName = collabState.name;
logger(`running action for ${stateName}, context=`, context);
const debugString = `runActions:${stateName}`;
const base = {
clientInfo,
context: context,
logger,
signal,
view,
};
switch (stateName) {
case StateName.FatalError: {
logger(`Freezing document(${context.docName}) to prevent further edits due to FatalError`);
freezeDoc(view);
case CollabStateName.FatalError: {
logger(`Freezing document(${clientInfo.docName}) to prevent further edits due to FatalError`);
return;
}
case StateName.InitDoc: {
const { initialDoc, initialVersion, initialSelection } = state.state;
case CollabStateName.InitDoc: {
const { initialDoc, initialVersion, initialSelection } = collabState.state;
if (!signal.aborted) {
applyDoc(view, initialDoc, initialVersion, initialSelection);
dispatch({
type: EventType.Ready,
}, debugString);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Ready,
},
context: {
debugInfo: debugString,
},
})(view.state, view.dispatch);
}
return;
}
case StateName.InitError: {
return handleErrorStateAction(context, dispatch, logger, signal, state);
case CollabStateName.InitError: {
return handleErrorStateAction({ ...base, collabState });
}
case StateName.Init: {
return initStateAction(context, dispatch, logger, signal);
case CollabStateName.Init: {
return initStateAction({ ...base, collabState });
}
case StateName.Pull: {
return pullStateAction(context, dispatch, logger, signal, state);
case CollabStateName.Pull: {
return pullStateAction({ ...base, collabState });
}
case StateName.PushPullError: {
return handleErrorStateAction(context, dispatch, logger, signal, state);
case CollabStateName.PushPullError: {
return handleErrorStateAction({ ...base, collabState });
}
case StateName.Push: {
return pushStateAction(context, dispatch, logger, signal, state);
case CollabStateName.Push: {
return pushStateAction({ ...base, collabState });
}
// Ready state is a special state where pending changes are dispatched
// or if no pending changes, it waits for new changes.
case StateName.Ready: {
case CollabStateName.Ready: {
if (!signal.aborted) {
if (context.pendingUpstreamChange) {
context.pendingUpstreamChange = false;
dispatch({ type: EventType.Pull }, debugString);
if (isOutdatedVersion()(view.state)) {
dispatchCollabPluginEvent({
context: {
...context,
debugInfo: debugString + '(outdated-local-version)',
},
collabEvent: {
type: EventType.Pull,
},
})(view.state, view.dispatch);
}
else if (context.pendingPush) {
context.pendingPush = false;
dispatch({ type: EventType.Push }, debugString);
else if (sendableSteps(view.state)) {
dispatchCollabPluginEvent({
context: {
...context,
debugInfo: debugString + '(sendable-steps)',
},
collabEvent: {
type: EventType.Push,
},
})(view.state, view.dispatch);
}

@@ -357,4 +520,4 @@ }

}
async function initStateAction(context, dispatch, logger, signal, state) {
const { docName, schema, userId, sendManagerRequest } = context;
const initStateAction = async ({ clientInfo, context, logger, signal, view, }) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const debugSource = `initStateAction:`;

@@ -372,23 +535,29 @@ const result = await sendManagerRequest({

if (!result.ok) {
dispatch({
type: EventType.InitError,
payload: { failure: result.body },
}, debugSource);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.InitError,
payload: { failure: result.body },
},
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
return;
}
const { doc, managerId, version } = result.body;
dispatch({
type: EventType.InitDoc,
payload: {
doc: schema.nodeFromJSON(doc),
version,
managerId,
selection: undefined,
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.InitDoc,
payload: {
doc: view.state.schema.nodeFromJSON(doc),
version,
managerId,
selection: undefined,
},
},
}, debugSource);
})(view.state, view.dispatch);
return;
}
async function pullStateAction(context, dispatch, logger, signal, state) {
const { docName, userId, view, sendManagerRequest } = context;
const { managerId } = state.state;
};
const pullStateAction = async ({ clientInfo, logger, signal, collabState, view, }) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const { managerId } = collabState.state;
const response = await sendManagerRequest({

@@ -409,20 +578,25 @@ type: CollabRequestType.PullEvents,

applySteps(view, response.body, logger);
dispatch({
type: EventType.Ready,
}, debugSource);
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.Ready,
},
})(view.state, view.dispatch);
}
else {
dispatch({
type: EventType.PushPullError,
payload: { failure: response.body },
}, debugSource);
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.PushPullError,
payload: { failure: response.body },
},
})(view.state, view.dispatch);
}
return;
}
function handleErrorStateAction(context, dispatch, logger, signal, state) {
const failure = state.state.failure;
};
const handleErrorStateAction = async ({ clientInfo, view, logger, signal, collabState }) => {
const failure = collabState.state.failure;
logger('Recovering failure', failure);
const debugSource = `pushPullErrorStateAction:${failure}:`;
switch (failure) {
// 400, 410
case CollabFail.InvalidVersion:

@@ -432,7 +606,10 @@ case CollabFail.IncorrectManager: {

if (!signal.aborted) {
dispatch({
type: EventType.Restart,
}, debugSource);
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.Restart,
},
})(view.state, view.dispatch);
}
}, signal, context.retryWaitTime);
}, signal, clientInfo.retryWaitTime);
return;

@@ -443,8 +620,11 @@ }

if (!signal.aborted) {
dispatch({
type: EventType.FatalError,
payload: {
message: 'History/Server not available',
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.FatalError,
payload: {
message: 'History/Server not available',
},
},
}, debugSource);
})(view.state, view.dispatch);
}

@@ -456,8 +636,11 @@ return;

if (!signal.aborted) {
dispatch({
type: EventType.FatalError,
payload: {
message: 'Document not found',
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.FatalError,
payload: {
message: 'Document not found',
},
},
}, debugSource);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}

@@ -468,5 +651,8 @@ return;

case CollabFail.OutdatedVersion: {
dispatch({
type: EventType.Pull,
}, debugSource);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
return;

@@ -478,7 +664,10 @@ }

if (!signal.aborted) {
dispatch({
type: EventType.Pull,
}, debugSource);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}
}, signal, context.retryWaitTime);
}, signal, clientInfo.retryWaitTime);
return;

@@ -490,14 +679,17 @@ }

}
}
async function pushStateAction(context, dispatch, logger, signal, state) {
const { docName, userId, view, sendManagerRequest } = context;
};
const pushStateAction = async ({ clientInfo, signal, collabState, view, }) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const debugSource = `pushStateAction:`;
const steps = sendableSteps(view.state);
if (!steps) {
dispatch({
type: EventType.Ready,
}, debugSource + '(no steps):');
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Ready,
},
context: { debugInfo: debugSource + '(no steps):' },
})(view.state, view.dispatch);
return;
}
const { managerId } = state.state;
const { managerId } = collabState.state;
const response = await sendManagerRequest({

@@ -521,125 +713,39 @@ type: CollabRequestType.PushEvents,

// get any new steps from other clients
dispatch({
type: EventType.Pull,
}, debugSource);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}
else {
dispatch({
type: EventType.PushPullError,
payload: { failure: response.body },
}, debugSource);
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.PushPullError,
payload: { failure: response.body },
},
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}
return;
}
};
const plugins = pluginsFactory;
const commands = {
pullChanges,
};
const commands = { onUpstreamChanges };
const RECOVERY_BACK_OFF = 50;
const getCollabSettings = (state) => {
return collabSettingsKey.getState(state);
};
function pluginsFactory({ clientID = 'client-' + uuid(), docName, sendManagerRequest, retryWaitTime = 100, }) {
return () => {
return [
collab({
clientID,
}),
new Plugin({
key: collabSettingsKey,
state: {
init: (_, _state) => {
return {
docName: docName,
clientID: clientID,
userId: 'user-' + clientID,
ready: false,
};
},
apply: (tr, value) => {
if (tr.getMeta(collabSettingsKey)) {
return {
...value,
...tr.getMeta(collabSettingsKey),
};
}
return value;
},
},
filterTransaction(tr, state) {
// Don't allow transactions that modifies the document before
// collab is ready.
if (tr.docChanged) {
// Let collab client's setup tr's go through
if (tr.getMeta('bangle/allowUpdatingEditorState') === true) {
return true;
}
// prevent any other tr until state is ready
if (!collabSettingsKey.getState(state).ready) {
return false;
}
}
return true;
},
}),
collabMachinePlugin({
sendManagerRequest,
retryWaitTime,
}),
];
};
const userId = 'user-' + clientID;
return [
collab({
clientID,
}),
collabClientPlugin({
clientID,
docName,
retryWaitTime,
sendManagerRequest,
userId,
}),
];
}
function collabMachinePlugin({ sendManagerRequest, retryWaitTime, }) {
let instance;
return new Plugin({
key: collabPluginKey,
state: {
init() {
return null;
},
apply(tr, pluginState, _prevState, newState) {
if (!tr.getMeta('bangle/isRemote') &&
collabSettingsKey.getState(newState).ready) {
setTimeout(() => {
instance === null || instance === void 0 ? void 0 : instance.onLocalEdits();
}, 0);
}
if (tr.getMeta('bangle/collab-pull-changes')) {
setTimeout(() => {
instance === null || instance === void 0 ? void 0 : instance.onUpstreamChange();
}, 0);
}
return pluginState;
},
},
view(view) {
if (!instance) {
const collabSettings = getCollabSettings(view.state);
instance = collabClient({
docName: collabSettings.docName,
clientID: collabSettings.clientID,
userId: collabSettings.userId,
schema: view.state.schema,
sendManagerRequest,
retryWaitTime,
view,
});
}
return {
update() { },
destroy() {
instance === null || instance === void 0 ? void 0 : instance.destroy();
instance = undefined;
},
};
},
});
}
function pullChanges() {
return (state, dispatch) => {
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta('bangle/collab-pull-changes', true));
return true;
};
}

@@ -650,7 +756,5 @@ var collabExtension = /*#__PURE__*/Object.freeze({

commands: commands,
RECOVERY_BACK_OFF: RECOVERY_BACK_OFF,
getCollabSettings: getCollabSettings,
pullChanges: pullChanges
RECOVERY_BACK_OFF: RECOVERY_BACK_OFF
});
export { collabExtension as collabClient };
{
"name": "@bangle.dev/collab-client",
"version": "0.30.0-alpha.4",
"version": "0.30.0-alpha.5",
"homepage": "https://bangle.dev",

@@ -43,8 +43,8 @@ "authors": [

"devDependencies": {
"@bangle.dev/all-base-components": "0.30.0-alpha.4",
"@bangle.dev/collab-server": "0.30.0-alpha.4",
"@bangle.dev/core": "0.30.0-alpha.4",
"@bangle.dev/disk": "0.30.0-alpha.4",
"@bangle.dev/pm": "0.30.0-alpha.4",
"@bangle.dev/test-helpers": "0.30.0-alpha.4",
"@bangle.dev/all-base-components": "0.30.0-alpha.5",
"@bangle.dev/collab-server": "0.30.0-alpha.5",
"@bangle.dev/core": "0.30.0-alpha.5",
"@bangle.dev/disk": "0.30.0-alpha.5",
"@bangle.dev/pm": "0.30.0-alpha.5",
"@bangle.dev/test-helpers": "0.30.0-alpha.5",
"@types/node": "^17.0.43",

@@ -56,3 +56,3 @@ "localforage": "^1.9.0",

"dependencies": {
"@bangle.dev/utils": "0.30.0-alpha.4",
"@bangle.dev/utils": "0.30.0-alpha.5",
"@types/jest": "^27.5.2",

@@ -59,0 +59,0 @@ "prosemirror-collab": "^1.3.0",

import { getVersion, sendableSteps } from 'prosemirror-collab';
import { CollabFail, CollabRequestType } from '@bangle.dev/collab-server';
import {
CollabFail,
CollabManager,
CollabRequestType,
} from '@bangle.dev/collab-server';
import { EditorState, EditorView, Plugin } from '@bangle.dev/pm';
import { abortableSetTimeout, isTestEnv } from '@bangle.dev/utils';
import {
Context,
dispatchCollabPluginEvent,
isCollabStateReady,
isNoEditState,
isOutdatedVersion,
onLocalChanges,
} from './commands';
import {
collabClientKey,
CollabPluginContext,
CollabPluginState,
CollabSettings,
collabSettingsKey,
CollabStateName,
EventType,

@@ -14,7 +31,7 @@ InitErrorState,

PushState,
StateName,
TrMeta,
ValidEvents,
ValidStates,
ValidStates as ValidCollabStates,
} from './common';
import { applyDoc, applySteps, freezeDoc } from './helpers';
import { applyDoc, applySteps } from './helpers';

@@ -26,76 +43,195 @@ const LOG = true;

const MAX_RESTARTS = 100;
interface ClientInfo {
readonly clientID: string;
readonly docName: string;
readonly retryWaitTime: number;
readonly sendManagerRequest: CollabManager['handleRequest'];
readonly userId: string;
}
export function collabClient(
param: Omit<
Context,
'pendingPush' | 'pendingUpstreamChange' | 'restartCount'
>,
) {
const logger = (...args: any[]) =>
log(
`${param.clientID}:version=${getVersion(context.view.state)}:`,
...args,
);
export function collabClientPlugin(clientInfo: ClientInfo) {
const logger =
(state: EditorState) =>
(...args: any[]) =>
log(
`${clientInfo.clientID}:version=${getVersion(state)}:${
collabClientKey.getState(state)?.context.debugInfo ?? ''
}`,
...args,
);
const context: Context = {
...param,
pendingPush: false,
pendingUpstreamChange: false,
restartCount: 0,
};
return [
new Plugin({
key: collabClientKey,
filterTransaction(tr, state) {
// Do not block collab plugins' transactions
if (
tr.getMeta(collabClientKey) ||
tr.getMeta(collabSettingsKey) ||
tr.getMeta('bangle.dev/isRemote')
) {
return true;
}
let gState: ValidStates = { name: StateName.Init };
let controller = new AbortController();
// prevent any other tr until state is in one of the no-edit state
if (tr.docChanged && isNoEditState()(state)) {
logger(state)('skipping transaction');
return false;
}
const dispatchEvent = (event: ValidEvents, debugString?: string): void => {
if (event.type === EventType.Restart) {
context.restartCount++;
}
return true;
},
state: {
init(): CollabPluginState {
return {
context: {
restartCount: 0,
debugInfo: undefined,
},
collabState: { name: CollabStateName.Init },
};
},
apply(tr, value, oldState, newState) {
const meta: TrMeta | undefined = tr.getMeta(collabClientKey);
if (context.restartCount > MAX_RESTARTS) {
freezeDoc(context.view);
throw new Error('Too many restarts');
}
if (meta === undefined) {
return value;
}
let result: CollabPluginState = {
...value,
context: {
...value.context,
// unset debugInfo everytime
debugInfo: undefined,
},
};
const state = applyState(gState, event, logger);
if (meta.collabEvent) {
const newPluginState = applyState(
value.collabState,
meta.collabEvent,
logger(newState),
);
if (newPluginState.name !== value.collabState.name) {
result.collabState = newPluginState;
} else {
logger(newState)(
'applyState',
`debugInfo=${result.context.debugInfo}`,
`event ${meta.collabEvent.type} ignored`,
);
}
}
// only update state if it changed, we donot support self transitions
if (gState.name !== state.name) {
controller.abort();
controller = new AbortController();
logger(
debugString ? `from=${debugString}:` : '',
`event ${event.type} changed state from ${gState.name} to ${state.name}`,
);
gState = state;
runActions(context, state, controller.signal, dispatchEvent, logger);
} else {
logger(debugString, `event ${event.type} ignored`);
}
};
if (meta.context) {
result.context = { ...result.context, ...meta.context };
}
runActions(context, gState, controller.signal, dispatchEvent, logger);
logger(newState)(
'apply state, newStateName=',
result.collabState.name,
`debugInfo=${result.context.debugInfo}`,
'oldStateName=',
value.collabState.name,
);
return {
onUpstreamChange() {
if (gState.name === StateName.Ready) {
dispatchEvent({ type: EventType.Pull }, 'onUpstreamChange');
} else {
context.pendingUpstreamChange = true;
}
},
return result;
},
},
onLocalEdits() {
if (gState.name === StateName.Ready) {
dispatchEvent({ type: EventType.Push }, 'onLocalEdits');
} else {
context.pendingPush = true;
}
},
view(view) {
let controller = new AbortController();
const pluginState = collabClientKey.getState(view.state);
if (pluginState) {
runActions(
clientInfo,
pluginState.collabState,
pluginState.context,
view,
controller.signal,
logger(view.state),
);
}
async destroy() {
controller.abort();
},
};
return {
destroy() {
controller.abort();
},
update(view, prevState) {
const pluginState = collabClientKey.getState(view.state);
// ignore running actions if collab state didn't change
if (
pluginState?.collabState ===
collabClientKey.getState(prevState)?.collabState
) {
return;
}
if (pluginState) {
controller.abort();
controller = new AbortController();
runActions(
clientInfo,
pluginState.collabState,
pluginState.context,
view,
controller.signal,
logger(view.state),
);
}
},
};
},
}),
new Plugin({
key: collabSettingsKey,
state: {
init: (_, _state): CollabSettings => {
return {
serverVersion: undefined,
};
},
apply: (tr, value, oldState, newState) => {
const meta = tr.getMeta(collabSettingsKey);
if (meta) {
logger(newState)('collabSettingsKey received tr', meta);
return {
...value,
...meta,
};
}
return value;
},
},
view(view) {
// // If there are sendable steps to send to server
if (sendableSteps(view.state)) {
onLocalChanges()(view.state, view.dispatch);
}
return {
update(view) {
if (
isOutdatedVersion()(view.state) &&
isCollabStateReady()(view.state)
) {
dispatchCollabPluginEvent({
context: {
debugInfo: 'collabSettingsKey(outdated-local-version)',
},
collabEvent: {
type: EventType.Pull,
},
})(view.state, view.dispatch);
}
// // If there are sendable steps to send to server
if (sendableSteps(view.state)) {
onLocalChanges()(view.state, view.dispatch);
}
},
};
},
}),
];
}

@@ -105,7 +241,7 @@

export function applyState(
state: ValidStates,
state: ValidCollabStates,
event: ValidEvents,
logger: (...args: any[]) => void,
): ValidStates {
const logIgnoreEvent = (state: ValidStates, event: ValidEvents) => {
): ValidCollabStates {
const logIgnoreEvent = (state: ValidCollabStates, event: ValidEvents) => {
logger(

@@ -121,3 +257,3 @@ 'applyState:',

// FatalError is terminal and should not allow at state transitions
case StateName.FatalError: {
case CollabStateName.FatalError: {
logIgnoreEvent(state, event);

@@ -127,5 +263,5 @@ return state;

case StateName.InitDoc: {
case CollabStateName.InitDoc: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
}

@@ -137,8 +273,8 @@

case StateName.InitError: {
case CollabStateName.InitError: {
if (event.type === EventType.Restart) {
return { name: StateName.Init };
return { name: CollabStateName.Init };
} else if (event.type === EventType.FatalError) {
return {
name: StateName.FatalError,
name: CollabStateName.FatalError,
state: { message: event.payload.message },

@@ -152,7 +288,7 @@ };

case StateName.Init: {
case CollabStateName.Init: {
if (event.type === EventType.InitDoc) {
const { payload } = event;
return {
name: StateName.InitDoc,
name: CollabStateName.InitDoc,
state: {

@@ -167,3 +303,3 @@ initialDoc: payload.doc,

return {
name: StateName.InitError,
name: CollabStateName.InitError,
state: { failure: event.payload.failure },

@@ -177,8 +313,8 @@ };

case StateName.Pull: {
case CollabStateName.Pull: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
} else if (event.type === EventType.PushPullError) {
return {
name: StateName.PushPullError,
name: CollabStateName.PushPullError,
state: {

@@ -195,10 +331,10 @@ failure: event.payload.failure,

case StateName.PushPullError: {
case CollabStateName.PushPullError: {
if (event.type === EventType.Restart) {
return { name: StateName.Init };
return { name: CollabStateName.Init };
} else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state.initDocState };
return { name: CollabStateName.Pull, state: state.state.initDocState };
} else if (event.type === EventType.FatalError) {
return {
name: StateName.FatalError,
name: CollabStateName.FatalError,
state: { message: event.payload.message },

@@ -212,10 +348,10 @@ };

case StateName.Push: {
case CollabStateName.Push: {
if (event.type === EventType.Ready) {
return { name: StateName.Ready, state: state.state };
return { name: CollabStateName.Ready, state: state.state };
} else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state };
return { name: CollabStateName.Pull, state: state.state };
} else if (event.type === EventType.PushPullError) {
return {
name: StateName.PushPullError,
name: CollabStateName.PushPullError,
state: {

@@ -232,7 +368,7 @@ failure: event.payload.failure,

case StateName.Ready: {
case CollabStateName.Ready: {
if (event.type === EventType.Push) {
return { name: StateName.Push, state: state.state };
return { name: CollabStateName.Push, state: state.state };
} else if (event.type === EventType.Pull) {
return { name: StateName.Pull, state: state.state };
return { name: CollabStateName.Pull, state: state.state };
}

@@ -253,6 +389,7 @@

export async function runActions(
context: Context,
state: ValidStates,
clientInfo: ClientInfo,
collabState: ValidCollabStates,
context: CollabPluginContext,
view: EditorView,
signal: AbortSignal,
dispatch: (event: ValidEvents, debugString?: string) => void,
logger: (...args: any[]) => void,

@@ -264,28 +401,36 @@ ): Promise<void> {

const stateName = state.name;
const { view } = context;
const stateName = collabState.name;
logger(`running action for ${stateName}`);
logger(`running action for ${stateName}, context=`, context);
const debugString = `runActions:${stateName}`;
const base = {
clientInfo,
context: context,
logger,
signal,
view,
};
switch (stateName) {
case StateName.FatalError: {
case CollabStateName.FatalError: {
logger(
`Freezing document(${context.docName}) to prevent further edits due to FatalError`,
`Freezing document(${clientInfo.docName}) to prevent further edits due to FatalError`,
);
freezeDoc(view);
return;
}
case StateName.InitDoc: {
const { initialDoc, initialVersion, initialSelection } = state.state;
case CollabStateName.InitDoc: {
const { initialDoc, initialVersion, initialSelection } =
collabState.state;
if (!signal.aborted) {
applyDoc(view, initialDoc, initialVersion, initialSelection);
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Ready,
},
debugString,
);
context: {
debugInfo: debugString,
},
})(view.state, view.dispatch);
}

@@ -295,20 +440,20 @@ return;

case StateName.InitError: {
return handleErrorStateAction(context, dispatch, logger, signal, state);
case CollabStateName.InitError: {
return handleErrorStateAction({ ...base, collabState });
}
case StateName.Init: {
return initStateAction(context, dispatch, logger, signal, state);
case CollabStateName.Init: {
return initStateAction({ ...base, collabState });
}
case StateName.Pull: {
return pullStateAction(context, dispatch, logger, signal, state);
case CollabStateName.Pull: {
return pullStateAction({ ...base, collabState });
}
case StateName.PushPullError: {
return handleErrorStateAction(context, dispatch, logger, signal, state);
case CollabStateName.PushPullError: {
return handleErrorStateAction({ ...base, collabState });
}
case StateName.Push: {
return pushStateAction(context, dispatch, logger, signal, state);
case CollabStateName.Push: {
return pushStateAction({ ...base, collabState });
}

@@ -318,10 +463,24 @@

// or if no pending changes, it waits for new changes.
case StateName.Ready: {
case CollabStateName.Ready: {
if (!signal.aborted) {
if (context.pendingUpstreamChange) {
context.pendingUpstreamChange = false;
dispatch({ type: EventType.Pull }, debugString);
} else if (context.pendingPush) {
context.pendingPush = false;
dispatch({ type: EventType.Push }, debugString);
if (isOutdatedVersion()(view.state)) {
dispatchCollabPluginEvent({
context: {
...context,
debugInfo: debugString + '(outdated-local-version)',
},
collabEvent: {
type: EventType.Pull,
},
})(view.state, view.dispatch);
} else if (sendableSteps(view.state)) {
dispatchCollabPluginEvent({
context: {
...context,
debugInfo: debugString + '(sendable-steps)',
},
collabEvent: {
type: EventType.Push,
},
})(view.state, view.dispatch);
}

@@ -339,10 +498,19 @@ }

async function initStateAction(
context: Context,
dispatch: (event: ValidEvents, debugString?: string) => void,
logger: (...args: any[]) => void,
signal: AbortSignal,
state: InitState,
) {
const { docName, schema, userId, sendManagerRequest } = context;
type CollabAction<T extends ValidCollabStates> = (param: {
clientInfo: ClientInfo;
context: CollabPluginContext;
logger: (...args: any[]) => void;
signal: AbortSignal;
collabState: T;
view: EditorView;
}) => Promise<void>;
const initStateAction: CollabAction<InitState> = async ({
clientInfo,
context,
logger,
signal,
view,
}) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const debugSource = `initStateAction:`;

@@ -363,9 +531,9 @@

if (!result.ok) {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.InitError,
payload: { failure: result.body },
},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
return;

@@ -375,7 +543,8 @@ }

const { doc, managerId, version } = result.body;
dispatch(
{
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.InitDoc,
payload: {
doc: schema.nodeFromJSON(doc),
doc: view.state.schema.nodeFromJSON(doc),
version,

@@ -386,16 +555,16 @@ managerId,

},
debugSource,
);
})(view.state, view.dispatch);
return;
}
};
async function pullStateAction(
context: Context,
dispatch: (event: ValidEvents, debugString?: string) => void,
logger: (...args: any[]) => void,
signal: AbortSignal,
state: PullState,
): Promise<void> {
const { docName, userId, view, sendManagerRequest } = context;
const { managerId } = state.state;
const pullStateAction: CollabAction<PullState> = async ({
clientInfo,
logger,
signal,
collabState,
view,
}) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const { managerId } = collabState.state;
const response = await sendManagerRequest({

@@ -417,28 +586,24 @@ type: CollabRequestType.PullEvents,

applySteps(view, response.body, logger);
dispatch(
{
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.Ready,
},
debugSource,
);
})(view.state, view.dispatch);
} else {
dispatch(
{
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.PushPullError,
payload: { failure: response.body },
},
debugSource,
);
})(view.state, view.dispatch);
}
return;
}
};
function handleErrorStateAction(
context: Context,
dispatch: (event: ValidEvents, debugString?: string) => void,
logger: (...args: any[]) => void,
signal: AbortSignal,
state: PushPullErrorState | InitErrorState,
): void {
const failure = state.state.failure;
const handleErrorStateAction: CollabAction<
PushPullErrorState | InitErrorState
> = async ({ clientInfo, view, logger, signal, collabState }) => {
const failure = collabState.state.failure;
logger('Recovering failure', failure);

@@ -449,3 +614,2 @@

switch (failure) {
// 400, 410
case CollabFail.InvalidVersion:

@@ -456,12 +620,12 @@ case CollabFail.IncorrectManager: {

if (!signal.aborted) {
dispatch(
{
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.Restart,
},
debugSource,
);
})(view.state, view.dispatch);
}
},
signal,
context.retryWaitTime,
clientInfo.retryWaitTime,
);

@@ -473,4 +637,5 @@ return;

if (!signal.aborted) {
dispatch(
{
dispatchCollabPluginEvent({
context: { debugInfo: debugSource },
collabEvent: {
type: EventType.FatalError,

@@ -481,4 +646,3 @@ payload: {

},
debugSource,
);
})(view.state, view.dispatch);
}

@@ -490,4 +654,4 @@ return;

if (!signal.aborted) {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.FatalError,

@@ -498,4 +662,4 @@ payload: {

},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}

@@ -506,8 +670,8 @@ return;

case CollabFail.OutdatedVersion: {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
return;

@@ -521,12 +685,12 @@ }

if (!signal.aborted) {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}
},
signal,
context.retryWaitTime,
clientInfo.retryWaitTime,
);

@@ -542,12 +706,11 @@

}
}
};
async function pushStateAction(
context: Context,
dispatch: (event: ValidEvents, debugString?: string) => void,
logger: (...args: any[]) => void,
signal: AbortSignal,
state: PushState,
) {
const { docName, userId, view, sendManagerRequest } = context;
const pushStateAction: CollabAction<PushState> = async ({
clientInfo,
signal,
collabState,
view,
}) => {
const { docName, userId, sendManagerRequest } = clientInfo;
const debugSource = `pushStateAction:`;

@@ -558,12 +721,12 @@

if (!steps) {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Ready,
},
debugSource + '(no steps):',
);
context: { debugInfo: debugSource + '(no steps):' },
})(view.state, view.dispatch);
return;
}
const { managerId } = state.state;
const { managerId } = collabState.state;

@@ -590,18 +753,18 @@ const response = await sendManagerRequest({

// get any new steps from other clients
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.Pull,
},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
} else {
dispatch(
{
dispatchCollabPluginEvent({
collabEvent: {
type: EventType.PushPullError,
payload: { failure: response.body },
},
debugSource,
);
context: { debugInfo: debugSource },
})(view.state, view.dispatch);
}
return;
}
};
import { collab } from 'prosemirror-collab';
import { CollabManager } from '@bangle.dev/collab-server';
import { Command, EditorState, Plugin } from '@bangle.dev/pm';
import { uuid } from '@bangle.dev/utils';
import { collabClient } from './collab-client';
import { collabPluginKey, collabSettingsKey } from './helpers';
import { collabClientPlugin } from './collab-client';
import { onUpstreamChanges } from './commands';

@@ -19,18 +18,6 @@ const LOG = false;

export const plugins = pluginsFactory;
export const commands = {
pullChanges,
};
export const commands = { onUpstreamChanges };
export const RECOVERY_BACK_OFF = 50;
interface CollabSettings {
docName: string;
clientID: string;
userId: string;
ready: boolean;
}
export const getCollabSettings = (state: EditorState): CollabSettings => {
return collabSettingsKey.getState(state);
};
function pluginsFactory({

@@ -47,119 +34,15 @@ clientID = 'client-' + uuid(),

}) {
return () => {
return [
collab({
clientID,
}),
new Plugin({
key: collabSettingsKey,
state: {
init: (_, _state) => {
return {
docName: docName,
clientID: clientID,
userId: 'user-' + clientID,
ready: false,
};
},
apply: (tr, value) => {
if (tr.getMeta(collabSettingsKey)) {
return {
...value,
...tr.getMeta(collabSettingsKey),
};
}
return value;
},
},
filterTransaction(tr, state) {
// Don't allow transactions that modifies the document before
// collab is ready.
if (tr.docChanged) {
// Let collab client's setup tr's go through
if (tr.getMeta('bangle/allowUpdatingEditorState') === true) {
return true;
}
// prevent any other tr until state is ready
if (!collabSettingsKey.getState(state).ready) {
log('skipping transaction');
return false;
}
}
return true;
},
}),
collabMachinePlugin({
sendManagerRequest,
retryWaitTime,
}),
];
};
const userId = 'user-' + clientID;
return [
collab({
clientID,
}),
collabClientPlugin({
clientID,
docName,
retryWaitTime,
sendManagerRequest,
userId,
}),
];
}
function collabMachinePlugin({
sendManagerRequest,
retryWaitTime,
}: {
sendManagerRequest: CollabManager['handleRequest'];
retryWaitTime: number;
}) {
let instance: ReturnType<typeof collabClient> | undefined;
return new Plugin({
key: collabPluginKey,
state: {
init() {
return null;
},
apply(tr, pluginState, _prevState, newState) {
if (
!tr.getMeta('bangle/isRemote') &&
collabSettingsKey.getState(newState).ready
) {
setTimeout(() => {
instance?.onLocalEdits();
}, 0);
}
if (tr.getMeta('bangle/collab-pull-changes')) {
setTimeout(() => {
instance?.onUpstreamChange();
}, 0);
}
return pluginState;
},
},
view(view) {
if (!instance) {
const collabSettings = getCollabSettings(view.state);
instance = collabClient({
docName: collabSettings.docName,
clientID: collabSettings.clientID,
userId: collabSettings.userId,
schema: view.state.schema,
sendManagerRequest,
retryWaitTime,
view,
});
}
return {
update() {},
destroy() {
instance?.destroy();
instance = undefined;
},
};
},
});
}
export function pullChanges(): Command {
return (state, dispatch) => {
dispatch?.(state.tr.setMeta('bangle/collab-pull-changes', true));
return true;
};
}

@@ -1,4 +0,14 @@

import { CollabFail, CollabManager } from '@bangle.dev/collab-server';
import { EditorView, Node, Schema, TextSelection } from '@bangle.dev/pm';
import { CollabFail } from '@bangle.dev/collab-server';
import { Node, PluginKey, TextSelection } from '@bangle.dev/pm';
export const collabClientKey = new PluginKey<CollabPluginState>(
'bangle.dev/collab-client',
);
export interface CollabSettings {
serverVersion: undefined | number;
}
export const collabSettingsKey = new PluginKey<CollabSettings>(
'bangle/collabSettingsKey',
);
// Events

@@ -66,2 +76,3 @@ export type ValidEvents =

}
export interface RestartEvent {

@@ -82,3 +93,3 @@ type: EventType.Restart;

export enum StateName {
export enum CollabStateName {
FatalError = 'FATAL_ERROR_STATE',

@@ -95,3 +106,3 @@ Init = 'INIT_STATE',

export interface FatalErrorState {
name: StateName.FatalError;
name: CollabStateName.FatalError;
state: {

@@ -103,7 +114,7 @@ message: string;

export interface InitState {
name: StateName.Init;
name: CollabStateName.Init;
}
export interface InitDocState {
name: StateName.InitDoc;
name: CollabStateName.InitDoc;
state: {

@@ -118,3 +129,3 @@ initialDoc: Node;

export interface InitErrorState {
name: StateName.InitError;
name: CollabStateName.InitError;
state: {

@@ -126,3 +137,3 @@ failure: CollabFail;

export interface ReadyState {
name: StateName.Ready;
name: CollabStateName.Ready;
state: InitDocState['state'];

@@ -132,3 +143,3 @@ }

export interface PushState {
name: StateName.Push;
name: CollabStateName.Push;
state: InitDocState['state'];

@@ -138,3 +149,3 @@ }

export interface PullState {
name: StateName.Pull;
name: CollabStateName.Pull;
state: InitDocState['state'];

@@ -144,3 +155,3 @@ }

export interface PushPullErrorState {
name: StateName.PushPullError;
name: CollabStateName.PushPullError;
state: {

@@ -152,13 +163,14 @@ failure: CollabFail;

export interface Context {
readonly clientID: string;
readonly docName: string;
readonly retryWaitTime: number;
readonly schema: Schema;
readonly sendManagerRequest: CollabManager['handleRequest'];
readonly userId: string;
readonly view: EditorView;
pendingUpstreamChange: boolean;
pendingPush: boolean;
restartCount: number;
export interface CollabPluginContext {
readonly restartCount: number;
readonly debugInfo: string | undefined;
}
export interface CollabPluginState {
context: CollabPluginContext;
collabState: ValidStates;
}
export interface TrMeta {
context?: Partial<CollabPluginContext>;
collabEvent?: ValidEvents;
}

@@ -8,3 +8,2 @@ import { getVersion, receiveTransaction } from 'prosemirror-collab';

Node,
PluginKey,
Selection,

@@ -15,39 +14,2 @@ Step,

export const collabSettingsKey = new PluginKey('bangle/collabSettingsKey');
export const collabPluginKey = new PluginKey('bangle/collabPluginKey');
export function replaceDocument(
state: EditorState,
serializedDoc: any,
version?: number,
) {
const { schema, tr } = state;
const content: Node[] =
// TODO remove serializedDoc
serializedDoc instanceof Node
? serializedDoc.content
: (serializedDoc.content || []).map((child: any) =>
schema.nodeFromJSON(child),
);
const hasContent = Array.isArray(content)
? content.length > 0
: Boolean(content);
if (!hasContent) {
return tr;
}
tr.setMeta('addToHistory', false);
tr.replaceWith(0, state.doc.nodeSize - 2, content);
tr.setSelection(Selection.atStart(tr.doc));
if (typeof version !== undefined) {
const collabState = { version, unconfirmed: [] };
tr.setMeta('collab$', collabState);
}
return tr;
}
export function applySteps(

@@ -75,3 +37,3 @@ view: EditorView,

.setMeta('addToHistory', false)
.setMeta('bangle/isRemote', true);
.setMeta('bangle.dev/isRemote', true);
const newState = view.state.apply(tr);

@@ -84,11 +46,2 @@ view.updateState(newState);

// Prevent any changes in the document from happening
export function freezeDoc(view: EditorView) {
view.state.apply(
view.state.tr
.setMeta('bangle/isRemote', true)
.setMeta('bangle/allowUpdatingEditorState', false),
);
}
export function applyDoc(

@@ -119,10 +72,39 @@ view: EditorView,

const newState = view.state.apply(
tr
.setMeta('bangle/isRemote', true)
.setMeta('bangle/allowUpdatingEditorState', true),
);
const newState = view.state.apply(tr.setMeta('bangle.dev/isRemote', true));
view.updateState(newState);
view.dispatch(view.state.tr.setMeta(collabSettingsKey, { ready: true }));
}
function replaceDocument(
state: EditorState,
serializedDoc: any,
version?: number,
) {
const { schema, tr } = state;
const content: Node[] =
// TODO remove serializedDoc
serializedDoc instanceof Node
? serializedDoc.content
: (serializedDoc.content || []).map((child: any) =>
schema.nodeFromJSON(child),
);
const hasContent = Array.isArray(content)
? content.length > 0
: Boolean(content);
if (!hasContent) {
return tr;
}
tr.setMeta('addToHistory', false);
tr.replaceWith(0, state.doc.nodeSize - 2, content);
tr.setSelection(Selection.atStart(tr.doc));
if (typeof version !== undefined) {
const collabState = { version, unconfirmed: [] };
tr.setMeta('collab$', collabState);
}
return tr;
}

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc