diff --git a/cypress/e2e/sync.spec.js b/cypress/e2e/sync.spec.js index fce1e3edbb2..a0cf5fe9035 100644 --- a/cypress/e2e/sync.spec.js +++ b/cypress/e2e/sync.spec.js @@ -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 }) diff --git a/cypress/support/sessions.js b/cypress/support/sessions.js index ed865f3e79b..2fb7166ba96 100644 --- a/cypress/support/sessions.js +++ b/cypress/support/sessions.js @@ -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, '') @@ -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 = {}) { diff --git a/src/apis/Connect.ts b/src/apis/Connect.ts index aa615392946..6c94c54a8d0 100644 --- a/src/apis/Connect.ts +++ b/src/apis/Connect.ts @@ -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' diff --git a/src/apis/Save.ts b/src/apis/Save.ts new file mode 100644 index 00000000000..cb94a3bdc23 --- /dev/null +++ b/src/apis/Save.ts @@ -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, + data: SaveData, +): Promise { + 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, +): 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) +} diff --git a/src/apis/Sync.ts b/src/apis/Sync.ts index 36077faecb5..02896ceb389 100644 --- a/src/apis/Sync.ts +++ b/src/apis/Sync.ts @@ -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' diff --git a/src/components/Editor.vue b/src/components/Editor.vue index bd818f21abc..1019354d84d 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -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) diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 584199c3562..a4b5dc69fb7 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -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 export const provideSaveService = ( + connection: ShallowRef, syncService: SyncService, serialize: () => string, ydoc: Doc, ) => { const saveService = new SaveService({ + connection, syncService, serialize, getDocumentState: () => getDocumentState(ydoc), diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index b640d99b043..d68f4f5bff1 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -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 @@ -18,20 +19,24 @@ import type { SyncService } from './SyncService.js' const AUTOSAVE_INTERVAL = 30000 class SaveService { + connection: ShallowRef syncService serialize getDocumentState autosave constructor({ + connection, syncService, serialize, getDocumentState, }: { + connection: ShallowRef syncService: SyncService serialize: () => string getDocumentState: () => string }) { + this.connection = connection this.syncService = syncService this.serialize = serialize this.getDocumentState = getDocumentState @@ -41,10 +46,6 @@ class SaveService { }) } - get connection() { - return this.syncService.sessionConnection - } - get version() { return this.syncService.version } @@ -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(), @@ -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') } diff --git a/src/services/SessionConnection.js b/src/services/SessionConnection.js index 1a83cfdad70..05ce63aac6f 100644 --- a/src/services/SessionConnection.js +++ b/src/services/SessionConnection.js @@ -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 { @@ -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 @@ -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`), {