Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cypress/e2e/sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ describe('Sync', () => {
.should('include', 'saves the doc state')
})

it('saves via sendBeacon on unload', () => {
cy.visit('https://example.org')
cy.wait('@save').its('response.statusCode').should('eq', 200)
cy.testName()
.then(name => cy.downloadFile(`/${name}.md`))
.its('data')
.should('include', 'saves the doc state')
})

it('recovers from a short lost connection', () => {
cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead')
cy.wait('@dead', { timeout: 30000 })
Expand Down
21 changes: 13 additions & 8 deletions cypress/support/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import axios from '@nextcloud/axios'
import { SessionConnection } from '../../src/services/SessionConnection.js'
import { open, close } from '../../src/apis/Connect.ts'
import { push } from '../../src/apis/Sync.ts'
import { save } from '../../src/apis/Save.ts'

const url = Cypress.config('baseUrl').replace(/\/index.php\/?$/g, '')

Expand Down Expand Up @@ -56,16 +57,20 @@ Cypress.Commands.add('failToSyncSteps', (connection, options = { version: 0 }) =
}, (err) => err.response)
})

Cypress.Commands.add('save', (connection, options = { version: 0 }) => {
return connection.save(options)
.then(response => response.data)
Cypress.Commands.add('save', (sessionConnection, options = { version: 0 }) => {
return save(
sessionConnection.connection,
options
).then(response => response.data)
})

Cypress.Commands.add('failToSave', (connection, options = { version: 0 }) => {
return connection.save(options)
.then((response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
Cypress.Commands.add('failToSave', (sessionConnection, options = { version: 0 }) => {
return save(
sessionConnection.connection,
options
).then((response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
})

Cypress.Commands.add('sessionUsers', function(connection, bodyOptions = {}) {
Expand Down
1 change: 1 addition & 0 deletions src/apis/Connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import type { Connection } from '../composables/useConnection.js'
Expand Down
88 changes: 88 additions & 0 deletions src/apis/Save.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { getRequestToken } from '@nextcloud/auth'
import type { Connection } from '../composables/useConnection.js'
import { unref, type ShallowRef } from 'vue'
import { generateUrl } from '@nextcloud/router'
import type { Document } from '../services/SyncService.ts'

interface SaveData {
version: number
autosaveContent: string
documentState: string
force: boolean
manualSave: boolean
}

interface SaveResponse {
data: Document
}

/**
* Save document
* @param connection the active connection
* @param data data save
*/
export function save(
connection: ShallowRef<Connection> | Connection,
data: SaveData,
): Promise<SaveResponse> {
const con = unref(connection)
const pub = con.shareToken ? '/public' : ''
const url = generateUrl(`apps/text${pub}/session/${con.documentId}/save`)

return axios.post(url, {
documentId: con.documentId,
sessionId: con.sessionId,
sessionToken: con.sessionToken,
token: con.shareToken,
baseVersionEtag: con.baseVersionEtag,
filePath: con.filePath,
version: data.version,
autosaveContent: data.autosaveContent,
documentState: data.documentState,
force: data.force,
manualSave: data.manualSave,
})
}

/**
* Save document via `navigator.sendBeacon()`
* @param connection the active connection
* @param data data to save
*/
export function saveViaSendBeacon(
connection: Connection,
data: Omit<SaveData, 'force' | 'manualSave'>,
): boolean {
const con = unref(connection)
const pub = con.shareToken ? '/public' : ''
const url = generateUrl(`apps/text${pub}/session/${con.documentId}/save`)

const blob = new Blob(
[
JSON.stringify({
documentId: con.documentId,
sessionId: con.sessionId,
sessionToken: con.sessionToken,
token: con.shareToken,
baseVersionEtag: con.baseVersionEtag,
filePath: con.filePath,
version: data.version,
autosaveContent: data.autosaveContent,
documentState: data.documentState,
force: false,
manualSave: true,
requesttoken: getRequestToken() ?? '',
}),
],
{
type: 'application/json',
},
)
return navigator.sendBeacon(url, blob)
}
1 change: 1 addition & 0 deletions src/apis/Sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import type { Connection } from '../composables/useConnection.js'
import { unref, type ShallowRef } from 'vue'
Expand Down
7 changes: 6 additions & 1 deletion src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,12 @@ export default defineComponent({
)
: () => serializePlainText(editor.state.doc)

const { saveService } = provideSaveService(syncService, serialize, ydoc)
const { saveService } = provideSaveService(
connection,
syncService,
serialize,
ydoc,
)

const syncProvider = shallowRef(null)

Expand Down
5 changes: 4 additions & 1 deletion src/composables/useSaveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { type InjectionKey, provide, inject } from 'vue'
import { type InjectionKey, type ShallowRef, provide, inject } from 'vue'
import { SaveService } from '../services/SaveService.js'
import type { SyncService } from '../services/SyncService.ts'
import type { Connection } from './useConnection.ts'
import type { Doc } from 'yjs'
import { getDocumentState } from '../helpers/yjs.js'

const saveServiceKey = Symbol('text:save') as InjectionKey<SaveService>

export const provideSaveService = (
connection: ShallowRef<Connection | undefined>,
syncService: SyncService,
serialize: () => string,
ydoc: Doc,
) => {
const saveService = new SaveService({
connection,
syncService,
serialize,
getDocumentState: () => getDocumentState(ydoc),
Expand Down
24 changes: 13 additions & 11 deletions src/services/SaveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/* eslint-disable jsdoc/valid-types */

import debounce from 'debounce'

import type { ShallowRef } from 'vue'
import { logger } from '../helpers/logger.js'
import type { SyncService } from './SyncService.js'
import type { Connection } from '../composables/useConnection.ts'
import { save, saveViaSendBeacon } from '../apis/Save'

/**
* Interval to save the serialized document and the document state
Expand All @@ -18,20 +19,24 @@ import type { SyncService } from './SyncService.js'
const AUTOSAVE_INTERVAL = 30000

class SaveService {
connection: ShallowRef<Connection | undefined>
syncService
serialize
getDocumentState
autosave

constructor({
connection,
syncService,
serialize,
getDocumentState,
}: {
connection: ShallowRef<Connection | undefined>
syncService: SyncService
serialize: () => string
getDocumentState: () => string
}) {
this.connection = connection
this.syncService = syncService
this.serialize = serialize
this.getDocumentState = getDocumentState
Expand All @@ -41,10 +46,6 @@ class SaveService {
})
}

get connection() {
return this.syncService.sessionConnection
}

get version() {
return this.syncService.version
}
Expand All @@ -59,12 +60,12 @@ class SaveService {

async save({ force = false, manualSave = true } = {}) {
logger.debug('[SaveService] saving', { force, manualSave })
if (!this.connection) {
if (!this.connection.value) {
logger.warn('Could not save due to missing connection')
return
}
try {
const response = await this.connection.save({
const response = await save(this.connection.value, {
version: this.version,
autosaveContent: this._getContent(),
documentState: this.getDocumentState(),
Expand All @@ -82,12 +83,13 @@ class SaveService {
}

saveViaSendBeacon() {
this.connection?.saveViaSendBeacon({
if (!this.connection.value) {
return
}
saveViaSendBeacon(this.connection.value, {
version: this.version,
autosaveContent: this._getContent(),
documentState: this.getDocumentState(),
force: false,
manualSave: true,
}) && logger.debug('[SaveService] saved using sendBeacon')
}

Expand Down
42 changes: 2 additions & 40 deletions src/services/SessionConnection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { getRequestToken } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'

export class ConnectionClosedError extends Error {
Expand All @@ -21,24 +20,15 @@ export class SessionConnection {
#documentState
#document
#session
#lock
#readOnly
#hasOwner
connection

constructor(data, connection) {
const {
document,
session,
lock,
readOnly,
content,
documentState,
hasOwner,
} = data
const { document, session, readOnly, content, documentState, hasOwner } =
data
this.#document = document
this.#session = session
this.#lock = lock
this.#readOnly = readOnly
this.#content = content
this.#documentState = documentState
Expand Down Expand Up @@ -95,34 +85,6 @@ export class SessionConnection {
})
}

save(data) {
const url = this.#url(`session/${this.#document.id}/save`)
const postData = {
...this.#defaultParams,
filePath: this.connection.filePath,
baseVersionEtag: this.#document.baseVersionEtag,
...data,
}

return this.#post(url, postData)
}

saveViaSendBeacon(data) {
const url = this.#url(`session/${this.#document.id}/save`)
const postData = {
...this.#defaultParams,
filePath: this.connection.filePath,
baseVersionEtag: this.#document.baseVersionEtag,
...data,
requestToken: getRequestToken() ?? '',
}

const blob = new Blob([JSON.stringify(postData)], {
type: 'application/json',
})
return navigator.sendBeacon(url, blob)
}

// TODO: maybe return a new connection here so connections have immutable state
update(guestName) {
return this.#post(this.#url(`session/${this.#document.id}/session`), {
Expand Down
Loading