| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690 |
- /* eslint-disable camelcase */
- // Migrated from services/web/frontend/js/ide/editor/Document.js
- import RangesTracker from '@overleaf/ranges-tracker'
- import { ShareJsDoc } from './share-js-doc'
- import { debugConsole } from '@/utils/debugging'
- import { Socket } from '@/features/ide-react/connection/types/socket'
- import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
- import { EditorFacade } from '@/features/source-editor/extensions/realtime'
- import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
- import EventEmitter from '@/utils/EventEmitter'
- import {
- AnyOperation,
- Change,
- CommentOperation,
- EditOperation,
- } from '../../../../../types/change'
- import {
- isCommentOperation,
- isDeleteOperation,
- isInsertOperation,
- } from '@/utils/operations'
- import { decodeUtf8 } from '@/utils/decode-utf8'
- import {
- ShareJsOperation,
- TrackChangesIdSeeds,
- } from '@/features/ide-react/editor/types/document'
- import { ThreadId } from '../../../../../types/review-panel/review-panel'
- import getMeta from '@/utils/meta'
- const MAX_PENDING_OP_SIZE = 64
- type JoinCallback = (error?: Error) => void
- type LeaveCallback = JoinCallback
- type Update =
- | {
- v: number
- doc: string
- }
- | {
- v: number
- doc: string
- op: AnyOperation[]
- meta: {
- type?: string
- source: string
- user_id: string
- ts: number
- }
- hash?: string
- lastV?: number
- }
- type Message = {
- meta: {
- tc: string
- user_id: string
- }
- }
- type ErrorMetadata = Record<string, any>
- function getOpSize(op: AnyOperation) {
- if (isInsertOperation(op)) {
- return op.i.length
- }
- if (isDeleteOperation(op)) {
- return op.d.length
- }
- return 0
- }
- function getShareJsOpSize(shareJsOp: ShareJsOperation) {
- return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
- }
- // TODO: define these in RangesTracker
- type _RangesTracker = Omit<RangesTracker, 'changes' | 'comments'> & {
- changes: Change<EditOperation>[]
- comments: Change<CommentOperation>[]
- track_changes?: boolean
- }
- export type RangesTrackerWithResolvedThreadIds = _RangesTracker & {
- resolvedThreadIds: Record<ThreadId, boolean>
- }
- export class DocumentContainer extends EventEmitter {
- private connected: boolean
- private wantToBeJoined = false
- private chaosMonkeyTimer: number | null = null
- public track_changes_as: string | null = null
- private joinCallbacks: JoinCallback[] = []
- private leaveCallbacks: LeaveCallback[] = []
- doc?: ShareJsDoc
- cm6?: EditorFacade
- oldInflightOp?: ShareJsOperation
- ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds
- joined = false
- // This is set and read in useCodeMirrorScope
- docName = ''
- constructor(
- readonly doc_id: string,
- readonly socket: Socket,
- private readonly globalEditorWatchdogManager: EditorWatchdogManager,
- private readonly ideEventEmitter: IdeEventEmitter,
- private readonly detachDoc: (docId: string, doc: DocumentContainer) => void
- ) {
- super()
- this.connected = this.socket.socket.connected
- this.bindToEditorEvents()
- this.bindToSocketEvents()
- }
- attachToCM6(cm6: EditorFacade) {
- this.cm6 = cm6
- if (this.doc) {
- this.doc.attachToCM6(this.cm6)
- }
- this.cm6.on('change', this.checkConsistency)
- }
- detachFromCM6() {
- if (this.doc) {
- this.doc.detachFromCM6()
- }
- if (this.cm6) {
- this.cm6.off('change', this.checkConsistency)
- }
- delete this.cm6
- this.clearChaosMonkey()
- if (this.doc) {
- this.ideEventEmitter.emit('document:closed', this.doc)
- }
- }
- submitOp(...ops: AnyOperation[]) {
- this.doc?.submitOp(ops)
- }
- private checkConsistency = (editor: EditorFacade) => {
- // We've been seeing a lot of errors when I think there shouldn't be
- // any, which may be related to this check happening before the change is
- // applied. If we use a timeout, hopefully we can reduce this.
- window.setTimeout(() => {
- const editorValue = editor?.getValue()
- const sharejsValue = this.doc?.getSnapshot()
- if (editorValue !== sharejsValue) {
- return this.onError(
- new Error('Editor text does not match server text'),
- {},
- editorValue
- )
- }
- }, 0)
- }
- getSnapshot() {
- return this.doc?.getSnapshot()
- }
- getType() {
- return this.doc?.getType()
- }
- getInflightOp(): ShareJsOperation | undefined {
- return this.doc?.getInflightOp()
- }
- getPendingOp(): ShareJsOperation | undefined {
- return this.doc?.getPendingOp()
- }
- getRecentAck() {
- return this.doc?.getRecentAck()
- }
- hasBufferedOps() {
- return this.doc?.hasBufferedOps()
- }
- setTrackingChanges(track_changes: boolean) {
- if (this.doc) {
- this.doc.track_changes = track_changes
- }
- }
- getTrackingChanges() {
- return !!this.doc?.track_changes
- }
- setTrackChangesIdSeeds(id_seeds: TrackChangesIdSeeds) {
- if (this.doc) {
- this.doc.track_changes_id_seeds = id_seeds
- }
- }
- private onUpdateAppliedHandler = (update: any) => this.onUpdateApplied(update)
- private onErrorHandler = (error: Error, message: ErrorMetadata) => {
- // 'otUpdateError' are emitted per doc socket.io room, hence we can be
- // sure that message.doc_id exists.
- if (message.doc_id !== this.doc_id) {
- // This error is for another doc. Do not action it. We could open
- // a modal that has the wrong context on it.
- return
- }
- this.onError(error, message)
- }
- private onDisconnectHandler = () => this.onDisconnect()
- private bindToSocketEvents() {
- this.socket.on('otUpdateApplied', this.onUpdateAppliedHandler)
- this.socket.on('otUpdateError', this.onErrorHandler)
- return this.socket.on('disconnect', this.onDisconnectHandler)
- }
- private unBindFromSocketEvents() {
- this.socket.removeListener('otUpdateApplied', this.onUpdateAppliedHandler)
- this.socket.removeListener('otUpdateError', this.onErrorHandler)
- return this.socket.removeListener('disconnect', this.onDisconnectHandler)
- }
- private bindToEditorEvents() {
- this.ideEventEmitter.on('project:joined', this.onReconnect)
- }
- private unBindFromEditorEvents() {
- this.ideEventEmitter.off('project:joined', this.onReconnect)
- }
- leaveAndCleanUp(cb?: (error?: Error) => void) {
- return this.leave((error?: Error) => {
- this.cleanUp()
- if (cb) cb(error)
- })
- }
- leaveAndCleanUpPromise() {
- return new Promise<void>((resolve, reject) => {
- this.leaveAndCleanUp((error?: Error) => {
- if (error) {
- reject(error)
- } else {
- resolve()
- }
- })
- })
- }
- join(callback?: JoinCallback) {
- this.wantToBeJoined = true
- this.cancelLeave()
- if (this.connected) {
- this.joinDoc(callback)
- } else if (callback) {
- this.joinCallbacks.push(callback)
- }
- }
- leave(callback?: LeaveCallback) {
- this.flush() // force an immediate flush when leaving document
- this.wantToBeJoined = false
- this.cancelJoin()
- if (this.doc?.hasBufferedOps()) {
- debugConsole.log(
- '[leave] Doc has buffered ops, pushing callback for later'
- )
- if (callback) {
- this.leaveCallbacks.push(callback)
- }
- } else if (!this.connected) {
- debugConsole.log('[leave] Not connected, returning now')
- callback?.()
- } else {
- debugConsole.log('[leave] Leaving now')
- this.leaveDoc(callback)
- }
- }
- flush() {
- return this.doc?.flushPendingOps()
- }
- chaosMonkey(line = 0, char = 'a') {
- const orig = char
- let copy: string | null = null
- let pos = 0
- const timer = () => {
- if (copy == null || !copy.length) {
- copy = orig.slice() + ' ' + new Date() + '\n'
- line += Math.random() > 0.1 ? 1 : -2
- if (line < 0) {
- line = 0
- }
- pos = 0
- }
- char = copy[0]
- copy = copy.slice(1)
- if (this.cm6) {
- this.cm6.view.dispatch({
- changes: {
- from: Math.min(pos, this.cm6.view.state.doc.length),
- insert: char,
- },
- })
- }
- pos += 1
- this.chaosMonkeyTimer = window.setTimeout(
- timer,
- 100 + (Math.random() < 0.1 ? 1000 : 0)
- )
- }
- timer()
- }
- clearChaosMonkey() {
- const timer = this.chaosMonkeyTimer
- if (timer) {
- this.chaosMonkeyTimer = null
- window.clearTimeout(timer)
- }
- }
- pollSavedStatus() {
- // returns false if doc has ops waiting to be acknowledged or
- // sent that haven't changed since the last time we checked.
- // Otherwise returns true.
- let saved
- const inflightOp = this.getInflightOp()
- const pendingOp = this.getPendingOp()
- const recentAck = this.getRecentAck()
- const pendingOpSize = pendingOp ? getShareJsOpSize(pendingOp) : 0
- if (inflightOp == null && pendingOp == null) {
- // There's nothing going on, this is OK.
- saved = true
- debugConsole.log('[pollSavedStatus] no inflight or pending ops')
- } else if (inflightOp && inflightOp === this.oldInflightOp) {
- // The same inflight op has been sitting unacked since we
- // last checked, this is bad.
- saved = false
- debugConsole.log('[pollSavedStatus] inflight op is same as before')
- } else if (
- pendingOp != null &&
- recentAck &&
- pendingOpSize < MAX_PENDING_OP_SIZE
- ) {
- // There is an op waiting to go to server but it is small and
- // within the flushDelay, this is OK for now.
- saved = true
- debugConsole.log(
- '[pollSavedStatus] pending op (small with recent ack) assume ok',
- pendingOp,
- pendingOpSize
- )
- } else {
- // In any other situation, assume the document is unsaved.
- saved = false
- debugConsole.log(
- `[pollSavedStatus] assuming not saved (inflightOp?: ${
- inflightOp != null
- }, pendingOp?: ${pendingOp != null})`
- )
- }
- this.oldInflightOp = inflightOp
- return saved
- }
- private cancelLeave() {
- this.leaveCallbacks = []
- }
- private cancelJoin() {
- this.joinCallbacks = []
- }
- private onUpdateApplied(update: Update) {
- if (update?.doc === this.doc_id && this.doc != null) {
- // FIXME: change this back to processUpdateFromServer when redis fixed
- this.doc.processUpdateFromServerInOrder(update)
- if (!this.wantToBeJoined) {
- return this.leave()
- }
- }
- }
- private onDisconnect() {
- debugConsole.log('[onDisconnect] disconnecting')
- this.connected = false
- this.joined = false
- return this.doc != null
- ? this.doc.updateConnectionState('disconnected')
- : undefined
- }
- private onReconnect = () => {
- debugConsole.log('[onReconnect] reconnected (joined project)')
- this.connected = true
- if (this.wantToBeJoined || this.doc?.hasBufferedOps()) {
- debugConsole.log(
- `[onReconnect] Rejoining (wantToBeJoined: ${
- this.wantToBeJoined
- } OR hasBufferedOps: ${this.doc?.hasBufferedOps()})`
- )
- this.joinDoc((error?: Error) => {
- if (error) {
- this.onError(error)
- return
- }
- this.doc?.updateConnectionState('ok')
- this.doc?.flushPendingOps()
- this.callJoinCallbacks()
- })
- }
- }
- private callJoinCallbacks() {
- for (const callback of this.joinCallbacks) {
- callback()
- }
- this.joinCallbacks = []
- }
- private joinDoc(callback?: JoinCallback) {
- if (this.doc) {
- return this.socket.emit(
- 'joinDoc',
- this.doc_id,
- this.doc.getVersion(),
- { encodeRanges: true, age: this.doc.getTimeSinceLastServerActivity() },
- (error, docLines, version, updates, ranges) => {
- if (error) {
- callback?.(error)
- return
- }
- this.joined = true
- this.doc?.catchUp(updates)
- this.decodeRanges(ranges)
- this.catchUpRanges(ranges?.changes, ranges?.comments)
- callback?.()
- }
- )
- } else {
- this.socket.emit(
- 'joinDoc',
- this.doc_id,
- { encodeRanges: true },
- (error, docLines, version, updates, ranges) => {
- if (error) {
- callback?.(error)
- return
- }
- this.joined = true
- this.doc = new ShareJsDoc(
- this.doc_id,
- docLines,
- version,
- this.socket,
- this.globalEditorWatchdogManager,
- this.ideEventEmitter
- )
- this.decodeRanges(ranges)
- this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
- this.bindToShareJsDocEvents()
- callback?.()
- }
- )
- }
- }
- private decodeRanges(ranges: RangesTracker) {
- try {
- if (ranges.changes) {
- for (const change of ranges.changes) {
- if (isInsertOperation(change.op)) {
- change.op.i = decodeUtf8(change.op.i)
- }
- if (isDeleteOperation(change.op)) {
- change.op.d = decodeUtf8(change.op.d)
- }
- }
- }
- return (() => {
- if (!ranges.comments) {
- return []
- }
- return ranges.comments.map((comment: Change<CommentOperation>) =>
- comment.op.c != null
- ? (comment.op.c = decodeUtf8(comment.op.c))
- : undefined
- )
- })()
- } catch (err) {
- debugConsole.error(err)
- }
- }
- private leaveDoc(callback?: LeaveCallback) {
- debugConsole.log('[leaveDoc] Sending leaveDoc request')
- this.socket.emit('leaveDoc', this.doc_id, error => {
- if (error) {
- callback?.(error)
- return
- }
- this.joined = false
- for (const leaveCallback of this.leaveCallbacks) {
- debugConsole.log('[_leaveDoc] Calling buffered callback', leaveCallback)
- leaveCallback(error)
- }
- this.leaveCallbacks = []
- callback?.()
- })
- }
- cleanUp() {
- // if we arrive here from _onError the pending and inflight ops will have been cleared
- if (this.hasBufferedOps()) {
- debugConsole.log(
- `[cleanUp] Document (${this.doc_id}) has buffered ops, refusing to remove from openDocs`
- )
- return // return immediately, do not unbind from events
- }
- this.detachDoc(this.doc_id, this)
- this.unBindFromEditorEvents()
- this.unBindFromSocketEvents()
- }
- private bindToShareJsDocEvents() {
- if (!this.doc) {
- return
- }
- this.doc.on('error', (error: Error, meta: ErrorMetadata) =>
- this.onError(error, meta)
- )
- this.doc.on('externalUpdate', (update: Update) => {
- return this.trigger('externalUpdate', update)
- })
- this.doc.on('remoteop', (...ops: AnyOperation[]) => {
- return this.trigger('remoteop', ...ops)
- })
- this.doc.on('op:sent', (op: AnyOperation) => {
- return this.trigger('op:sent')
- })
- this.doc.on('op:acknowledged', (op: AnyOperation) => {
- this.ideEventEmitter.emit('ide:opAcknowledged', {
- doc_id: this.doc_id,
- op,
- })
- return this.trigger('op:acknowledged')
- })
- this.doc.on('op:timeout', (op: AnyOperation) => {
- this.trigger('op:timeout')
- return this.onError(new Error('op timed out'))
- })
- let docChangedTimeout: number | null = null
- this.doc.on(
- 'change',
- (ops: AnyOperation[], oldSnapshot: any, msg: Message) => {
- this.applyOpsToRanges(ops, msg)
- if (docChangedTimeout) {
- window.clearTimeout(docChangedTimeout)
- }
- docChangedTimeout = window.setTimeout(() => {
- window.dispatchEvent(
- new CustomEvent('doc:changed', { detail: { id: this.doc_id } })
- )
- this.ideEventEmitter.emit('doc:changed', { doc_id: this.doc_id })
- }, 50)
- }
- )
- this.doc.on('flipped_pending_to_inflight', () => {
- return this.trigger('flipped_pending_to_inflight')
- })
- let docSavedTimeout: number | null
- this.doc.on('saved', () => {
- if (docSavedTimeout) {
- window.clearTimeout(docSavedTimeout)
- }
- docSavedTimeout = window.setTimeout(() => {
- window.dispatchEvent(
- new CustomEvent('doc:saved', { detail: { id: this.doc_id } })
- )
- this.ideEventEmitter.emit('doc:saved', { doc_id: this.doc_id })
- }, 50)
- })
- }
- private onError(
- error: Error,
- meta: ErrorMetadata = {},
- editorContent?: string
- ) {
- meta.doc_id = this.doc_id
- debugConsole.log('ShareJS error', error, meta)
- if (error.message === 'no project_id found on client') {
- debugConsole.log('ignoring error, will wait to join project')
- return
- }
- if (this.doc) {
- this.doc.clearInflightAndPendingOps()
- }
- this.trigger('error', error, meta, editorContent)
- // The clean-up should run after the error is triggered because the error triggers a
- // disconnect. If we run the clean-up first, we remove our event handlers and miss
- // the disconnect event, which means we try to leaveDoc when the connection comes back.
- // This could interfere with the new connection of a new instance of this document.
- this.cleanUp()
- }
- private applyOpsToRanges(ops: AnyOperation[], msg?: Message) {
- let old_id_seed
- let track_changes_as = null
- const remote_op = msg != null
- if (remote_op && msg?.meta.tc) {
- old_id_seed = this.ranges!.getIdSeed()
- this.ranges!.setIdSeed(msg.meta.tc)
- track_changes_as = msg.meta.user_id
- } else if (!remote_op && this.track_changes_as != null) {
- track_changes_as = this.track_changes_as
- }
- this.ranges!.track_changes = track_changes_as != null
- for (const op of this.filterOps(ops)) {
- this.ranges!.applyOp(op, { user_id: track_changes_as })
- }
- if (old_id_seed != null) {
- this.ranges!.setIdSeed(old_id_seed)
- }
- if (remote_op) {
- // With remote ops, the editor hasn't been updated when we receive this
- // op, so defer updating track changes until it has
- return window.setTimeout(() => this.emit('ranges:dirty'))
- } else {
- return this.emit('ranges:dirty')
- }
- }
- private catchUpRanges(
- changes: Change<EditOperation>[],
- comments: Change<CommentOperation>[]
- ) {
- // We've just been given the current server's ranges, but need to apply any local ops we have.
- // Reset to the server state then apply our local ops again.
- if (changes == null) {
- changes = []
- }
- if (comments == null) {
- comments = []
- }
- this.emit('ranges:clear')
- this.ranges!.changes = changes
- this.ranges!.comments = comments
- this.ranges!.track_changes = this.doc?.track_changes ?? false
- for (const op of this.filterOps(this.doc?.getInflightOp() || [])) {
- this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
- this.ranges!.applyOp(op, { user_id: this.track_changes_as })
- }
- for (const op of this.filterOps(this.doc?.getPendingOp() || [])) {
- this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
- this.ranges!.applyOp(op, { user_id: this.track_changes_as })
- }
- return this.emit('ranges:redraw')
- }
- private filterOps(ops: AnyOperation[]) {
- // Read-only token users can't see/edit comment, so we filter out comment
- // ops to avoid highlighting comment ranges.
- if (getMeta('ol-isRestrictedTokenMember')) {
- return ops.filter(op => !isCommentOperation(op))
- } else {
- return ops
- }
- }
- }
|