Skip to content

Commit 09eaf95

Browse files
Merge pull request #7417 from nextcloud/refactor/save_service
chore(refactor): save service with new connection
2 parents 2a3534c + 58ffb0b commit 09eaf95

9 files changed

Lines changed: 137 additions & 61 deletions

File tree

cypress/e2e/sync.spec.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ describe('Sync', () => {
4646
.should('include', 'saves the doc state')
4747
})
4848

49+
it('saves via sendBeacon on unload', () => {
50+
cy.visit('https://example.org')
51+
cy.wait('@save').its('response.statusCode').should('eq', 200)
52+
cy.testName()
53+
.then(name => cy.downloadFile(`/${name}.md`))
54+
.its('data')
55+
.should('include', 'saves the doc state')
56+
})
57+
4958
it('recovers from a short lost connection', () => {
5059
cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead')
5160
cy.wait('@dead', { timeout: 30000 })

cypress/support/sessions.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import axios from '@nextcloud/axios'
77
import { SessionConnection } from '../../src/services/SessionConnection.js'
88
import { open, close } from '../../src/apis/Connect.ts'
99
import { push } from '../../src/apis/Sync.ts'
10+
import { save } from '../../src/apis/Save.ts'
1011

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

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

59-
Cypress.Commands.add('save', (connection, options = { version: 0 }) => {
60-
return connection.save(options)
61-
.then(response => response.data)
60+
Cypress.Commands.add('save', (sessionConnection, options = { version: 0 }) => {
61+
return save(
62+
sessionConnection.connection,
63+
options
64+
).then(response => response.data)
6265
})
6366

64-
Cypress.Commands.add('failToSave', (connection, options = { version: 0 }) => {
65-
return connection.save(options)
66-
.then((response) => {
67-
throw new Error('Expected request to fail - but it succeeded!')
68-
}, (err) => err.response)
67+
Cypress.Commands.add('failToSave', (sessionConnection, options = { version: 0 }) => {
68+
return save(
69+
sessionConnection.connection,
70+
options
71+
).then((response) => {
72+
throw new Error('Expected request to fail - but it succeeded!')
73+
}, (err) => err.response)
6974
})
7075

7176
Cypress.Commands.add('sessionUsers', function(connection, bodyOptions = {}) {

src/apis/Connect.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
56
import axios from '@nextcloud/axios'
67
import { generateUrl } from '@nextcloud/router'
78
import type { Connection } from '../composables/useConnection.js'

src/apis/Save.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import axios from '@nextcloud/axios'
7+
import { getRequestToken } from '@nextcloud/auth'
8+
import type { Connection } from '../composables/useConnection.js'
9+
import { unref, type ShallowRef } from 'vue'
10+
import { generateUrl } from '@nextcloud/router'
11+
import type { Document } from '../services/SyncService.ts'
12+
13+
interface SaveData {
14+
version: number
15+
autosaveContent: string
16+
documentState: string
17+
force: boolean
18+
manualSave: boolean
19+
}
20+
21+
interface SaveResponse {
22+
data: Document
23+
}
24+
25+
/**
26+
* Save document
27+
* @param connection the active connection
28+
* @param data data save
29+
*/
30+
export function save(
31+
connection: ShallowRef<Connection> | Connection,
32+
data: SaveData,
33+
): Promise<SaveResponse> {
34+
const con = unref(connection)
35+
const pub = con.shareToken ? '/public' : ''
36+
const url = generateUrl(`apps/text${pub}/session/${con.documentId}/save`)
37+
38+
return axios.post(url, {
39+
documentId: con.documentId,
40+
sessionId: con.sessionId,
41+
sessionToken: con.sessionToken,
42+
token: con.shareToken,
43+
baseVersionEtag: con.baseVersionEtag,
44+
filePath: con.filePath,
45+
version: data.version,
46+
autosaveContent: data.autosaveContent,
47+
documentState: data.documentState,
48+
force: data.force,
49+
manualSave: data.manualSave,
50+
})
51+
}
52+
53+
/**
54+
* Save document via `navigator.sendBeacon()`
55+
* @param connection the active connection
56+
* @param data data to save
57+
*/
58+
export function saveViaSendBeacon(
59+
connection: Connection,
60+
data: Omit<SaveData, 'force' | 'manualSave'>,
61+
): boolean {
62+
const con = unref(connection)
63+
const pub = con.shareToken ? '/public' : ''
64+
const url = generateUrl(`apps/text${pub}/session/${con.documentId}/save`)
65+
66+
const blob = new Blob(
67+
[
68+
JSON.stringify({
69+
documentId: con.documentId,
70+
sessionId: con.sessionId,
71+
sessionToken: con.sessionToken,
72+
token: con.shareToken,
73+
baseVersionEtag: con.baseVersionEtag,
74+
filePath: con.filePath,
75+
version: data.version,
76+
autosaveContent: data.autosaveContent,
77+
documentState: data.documentState,
78+
force: false,
79+
manualSave: true,
80+
requesttoken: getRequestToken() ?? '',
81+
}),
82+
],
83+
{
84+
type: 'application/json',
85+
},
86+
)
87+
return navigator.sendBeacon(url, blob)
88+
}

src/apis/Sync.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
56
import axios from '@nextcloud/axios'
67
import type { Connection } from '../composables/useConnection.js'
78
import { unref, type ShallowRef } from 'vue'

src/components/Editor.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,12 @@ export default defineComponent({
265265
)
266266
: () => serializePlainText(editor.state.doc)
267267
268-
const { saveService } = provideSaveService(syncService, serialize, ydoc)
268+
const { saveService } = provideSaveService(
269+
connection,
270+
syncService,
271+
serialize,
272+
ydoc,
273+
)
269274
270275
const syncProvider = shallowRef(null)
271276

src/composables/useSaveService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { type InjectionKey, provide, inject } from 'vue'
6+
import { type InjectionKey, type ShallowRef, provide, inject } from 'vue'
77
import { SaveService } from '../services/SaveService.js'
88
import type { SyncService } from '../services/SyncService.ts'
9+
import type { Connection } from './useConnection.ts'
910
import type { Doc } from 'yjs'
1011
import { getDocumentState } from '../helpers/yjs.js'
1112

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

1415
export const provideSaveService = (
16+
connection: ShallowRef<Connection | undefined>,
1517
syncService: SyncService,
1618
serialize: () => string,
1719
ydoc: Doc,
1820
) => {
1921
const saveService = new SaveService({
22+
connection,
2023
syncService,
2124
serialize,
2225
getDocumentState: () => getDocumentState(ydoc),

src/services/SaveService.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
/* eslint-disable jsdoc/valid-types */
7-
86
import debounce from 'debounce'
97

8+
import type { ShallowRef } from 'vue'
109
import { logger } from '../helpers/logger.js'
1110
import type { SyncService } from './SyncService.js'
11+
import type { Connection } from '../composables/useConnection.ts'
12+
import { save, saveViaSendBeacon } from '../apis/Save'
1213

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

2021
class SaveService {
22+
connection: ShallowRef<Connection | undefined>
2123
syncService
2224
serialize
2325
getDocumentState
2426
autosave
2527

2628
constructor({
29+
connection,
2730
syncService,
2831
serialize,
2932
getDocumentState,
3033
}: {
34+
connection: ShallowRef<Connection | undefined>
3135
syncService: SyncService
3236
serialize: () => string
3337
getDocumentState: () => string
3438
}) {
39+
this.connection = connection
3540
this.syncService = syncService
3641
this.serialize = serialize
3742
this.getDocumentState = getDocumentState
@@ -41,10 +46,6 @@ class SaveService {
4146
})
4247
}
4348

44-
get connection() {
45-
return this.syncService.sessionConnection
46-
}
47-
4849
get version() {
4950
return this.syncService.version
5051
}
@@ -59,12 +60,12 @@ class SaveService {
5960

6061
async save({ force = false, manualSave = true } = {}) {
6162
logger.debug('[SaveService] saving', { force, manualSave })
62-
if (!this.connection) {
63+
if (!this.connection.value) {
6364
logger.warn('Could not save due to missing connection')
6465
return
6566
}
6667
try {
67-
const response = await this.connection.save({
68+
const response = await save(this.connection.value, {
6869
version: this.version,
6970
autosaveContent: this._getContent(),
7071
documentState: this.getDocumentState(),
@@ -82,12 +83,13 @@ class SaveService {
8283
}
8384

8485
saveViaSendBeacon() {
85-
this.connection?.saveViaSendBeacon({
86+
if (!this.connection.value) {
87+
return
88+
}
89+
saveViaSendBeacon(this.connection.value, {
8690
version: this.version,
8791
autosaveContent: this._getContent(),
8892
documentState: this.getDocumentState(),
89-
force: false,
90-
manualSave: true,
9193
}) && logger.debug('[SaveService] saved using sendBeacon')
9294
}
9395

src/services/SessionConnection.js

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55
import axios from '@nextcloud/axios'
6-
import { getRequestToken } from '@nextcloud/auth'
76
import { generateUrl } from '@nextcloud/router'
87

98
export class ConnectionClosedError extends Error {
@@ -21,24 +20,15 @@ export class SessionConnection {
2120
#documentState
2221
#document
2322
#session
24-
#lock
2523
#readOnly
2624
#hasOwner
2725
connection
2826

2927
constructor(data, connection) {
30-
const {
31-
document,
32-
session,
33-
lock,
34-
readOnly,
35-
content,
36-
documentState,
37-
hasOwner,
38-
} = data
28+
const { document, session, readOnly, content, documentState, hasOwner } =
29+
data
3930
this.#document = document
4031
this.#session = session
41-
this.#lock = lock
4232
this.#readOnly = readOnly
4333
this.#content = content
4434
this.#documentState = documentState
@@ -95,34 +85,6 @@ export class SessionConnection {
9585
})
9686
}
9787

98-
save(data) {
99-
const url = this.#url(`session/${this.#document.id}/save`)
100-
const postData = {
101-
...this.#defaultParams,
102-
filePath: this.connection.filePath,
103-
baseVersionEtag: this.#document.baseVersionEtag,
104-
...data,
105-
}
106-
107-
return this.#post(url, postData)
108-
}
109-
110-
saveViaSendBeacon(data) {
111-
const url = this.#url(`session/${this.#document.id}/save`)
112-
const postData = {
113-
...this.#defaultParams,
114-
filePath: this.connection.filePath,
115-
baseVersionEtag: this.#document.baseVersionEtag,
116-
...data,
117-
requestToken: getRequestToken() ?? '',
118-
}
119-
120-
const blob = new Blob([JSON.stringify(postData)], {
121-
type: 'application/json',
122-
})
123-
return navigator.sendBeacon(url, blob)
124-
}
125-
12688
// TODO: maybe return a new connection here so connections have immutable state
12789
update(guestName) {
12890
return this.#post(this.#url(`session/${this.#document.id}/session`), {

0 commit comments

Comments
 (0)