Skip to content

Commit 9f32354

Browse files
nperez0111janthurau
authored andcommitted
feat: add beforeSync hook
1 parent eb133f4 commit 9f32354

File tree

8 files changed

+262
-3
lines changed

8 files changed

+262
-3
lines changed

docs/server/hooks.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ By way of illustration, if a user isn’t allowed to connect: Just throw an erro
4949
| `beforeBroadcastStateless` | Before broadcast a stateless message | [Read more](/server/hooks#before-broadcast-stateless) |
5050
| `afterUnloadDocument` | When a document is closed | [Read more](/server/hooks#after-unload-document) |
5151

52-
5352
## Usage
5453

5554
```js
@@ -759,7 +758,6 @@ const server = new Server({
759758
server.listen();
760759
```
761760
762-
763761
### onStateless
764762
765763
The `onStateless` hooks are called after the server has received a stateless message. It should return a Promise.
@@ -796,6 +794,37 @@ const server = new Server({
796794
server.listen()
797795
```
798796
797+
### beforeSync
798+
799+
The `beforeSync` hooks are called before a sync message is handled. This is useful if you want to inspect the sync message that will be applied to the document.
800+
801+
**Hook payload**
802+
803+
```js
804+
const data = {
805+
documentName: string,
806+
document: Document,
807+
// The y-protocols/sync message type
808+
type: number,
809+
// The payload of the y-protocols/sync message
810+
payload: Uint8Array,
811+
}
812+
```
813+
814+
**Example**
815+
816+
```js
817+
import { Server } from '@hocuspocus/server'
818+
819+
const server = new Server({
820+
async beforeSync({ payload, document, documentName, type }) {
821+
console.log(`Server will handle a sync message: "${payload}"!`)
822+
},
823+
})
824+
825+
server.listen()
826+
```
827+
799828
### beforeBroadcastStateless
800829
801830
The `beforeBroadcastStateless` hooks are called before the server broadcast a stateless message.

packages/server/src/ClientConnection.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { OutgoingMessage } from './OutgoingMessage.ts'
1616
import type {
1717
ConnectionConfiguration,
1818
beforeHandleMessagePayload,
19+
beforeSyncPayload,
1920
onDisconnectPayload,
2021
} from './types.ts'
2122
import {
@@ -199,6 +200,20 @@ export class ClientConnection {
199200
return this.hooks('beforeHandleMessage', beforeHandleMessagePayload)
200201
})
201202

203+
instance.beforeSync((connection, payload) => {
204+
const beforeSyncPayload: beforeSyncPayload = {
205+
clientsCount: document.getConnectionsCount(),
206+
context: hookPayload.context,
207+
document,
208+
documentName: document.name,
209+
connection,
210+
type: payload.type,
211+
payload: payload.payload,
212+
}
213+
214+
return this.hooks('beforeSync', beforeSyncPayload)
215+
})
216+
202217
return instance
203218
}
204219

packages/server/src/Connection.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type Document from './Document.ts'
88
import { IncomingMessage } from './IncomingMessage.ts'
99
import { MessageReceiver } from './MessageReceiver.ts'
1010
import { OutgoingMessage } from './OutgoingMessage.ts'
11-
import type { onStatelessPayload } from './types.ts'
11+
import type { beforeSyncPayload, onStatelessPayload } from './types.ts'
1212

1313
export class Connection {
1414

@@ -23,6 +23,7 @@ export class Connection {
2323
callbacks = {
2424
onClose: [(document: Document, event?: CloseEvent) => {}],
2525
beforeHandleMessage: (connection: Connection, update: Uint8Array) => Promise.resolve(),
26+
beforeSync: (connection: Connection, payload: Pick<beforeSyncPayload, 'type' | 'payload'>) => Promise.resolve(),
2627
statelessCallback: (payload: onStatelessPayload) => Promise.resolve(),
2728
}
2829

@@ -81,6 +82,15 @@ export class Connection {
8182
return this
8283
}
8384

85+
/**
86+
* Set a callback that will be triggered before a sync message is handled
87+
*/
88+
beforeSync(callback: (connection: Connection, payload: Pick<beforeSyncPayload, 'type' | 'payload'>) => Promise<any>): Connection {
89+
this.callbacks.beforeSync = callback
90+
91+
return this
92+
}
93+
8494
/**
8595
* Send the given message
8696
*/

packages/server/src/Hocuspocus.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class Hocuspocus {
4949
onConnect: () => new Promise(r => r(null)),
5050
connected: () => new Promise(r => r(null)),
5151
beforeHandleMessage: () => new Promise(r => r(null)),
52+
beforeSync: () => new Promise(r => r(null)),
5253
beforeBroadcastStateless: () => new Promise(r => r(null)),
5354
onStateless: () => new Promise(r => r(null)),
5455
onChange: () => new Promise(r => r(null)),
@@ -111,6 +112,7 @@ export class Hocuspocus {
111112
afterLoadDocument: this.configuration.afterLoadDocument,
112113
beforeHandleMessage: this.configuration.beforeHandleMessage,
113114
beforeBroadcastStateless: this.configuration.beforeBroadcastStateless,
115+
beforeSync: this.configuration.beforeSync,
114116
onStateless: this.configuration.onStateless,
115117
onChange: this.configuration.onChange,
116118
onStoreDocument: this.configuration.onStoreDocument,

packages/server/src/IncomingMessage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export class IncomingMessage {
4949
return readVarUint8Array(this.decoder)
5050
}
5151

52+
peekVarUint8Array() {
53+
const { pos } = this.decoder
54+
const result = readVarUint8Array(this.decoder)
55+
this.decoder.pos = pos
56+
return result
57+
}
58+
5259
readVarUint() {
5360
return readVarUint(this.decoder)
5461
}

packages/server/src/MessageReceiver.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export class MessageReceiver {
104104
readSyncMessage(message: IncomingMessage, document: Document, connection?: Connection, reply?: (message: Uint8Array) => void, requestFirstSync = true) {
105105
const type = message.readVarUint()
106106

107+
if (connection) {
108+
connection.callbacks.beforeSync(connection, {
109+
type,
110+
payload: message.peekVarUint8Array(),
111+
})
112+
}
113+
107114
switch (type) {
108115
case messageYjsSyncStep1: {
109116
readSyncStep1(message.decoder, message.encoder, document)

packages/server/src/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface Extension {
4444
onLoadDocument?(data: onLoadDocumentPayload): Promise<any>;
4545
afterLoadDocument?(data: afterLoadDocumentPayload): Promise<any>;
4646
beforeHandleMessage?(data: beforeHandleMessagePayload): Promise<any>;
47+
beforeSync?(data: beforeSyncPayload): Promise<any>;
4748
beforeBroadcastStateless?(data: beforeBroadcastStatelessPayload): Promise<any>;
4849
onStateless?(payload: onStatelessPayload): Promise<any>;
4950
onChange?(data: onChangePayload): Promise<any>;
@@ -69,6 +70,7 @@ export type HookName =
6970
'afterLoadDocument' |
7071
'beforeHandleMessage' |
7172
'beforeBroadcastStateless' |
73+
'beforeSync' |
7274
'onStateless' |
7375
'onChange' |
7476
'onStoreDocument' |
@@ -92,6 +94,7 @@ export type HookPayloadByName = {
9294
afterLoadDocument: afterLoadDocumentPayload,
9395
beforeHandleMessage: beforeHandleMessagePayload,
9496
beforeBroadcastStateless: beforeBroadcastStatelessPayload,
97+
beforeSync: beforeSyncPayload,
9598
onStateless: onStatelessPayload,
9699
onChange: onChangePayload,
97100
onStoreDocument: onStoreDocumentPayload,
@@ -250,6 +253,28 @@ export interface beforeHandleMessagePayload {
250253
connection: Connection
251254
}
252255

256+
export interface beforeSyncPayload {
257+
clientsCount: number,
258+
context: any,
259+
document: Document,
260+
documentName: string,
261+
connection: Connection,
262+
/**
263+
* The y-protocols/sync message type
264+
* @example
265+
* 0: SyncStep1
266+
* 1: SyncStep2
267+
* 2: YjsUpdate
268+
*
269+
* @see https://github.com/yjs/y-protocols/blob/master/sync.js#L13-L40
270+
*/
271+
type: number,
272+
/**
273+
* The payload of the y-sync message.
274+
*/
275+
payload: Uint8Array,
276+
}
277+
253278
export interface beforeBroadcastStatelessPayload {
254279
document: Document,
255280
documentName: string,

tests/server/beforeSync.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import test from 'ava'
2+
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
3+
import { retryableAssertion } from '../utils/retryableAssertion.ts'
4+
5+
test('beforeSync gets called in proper order', async t => {
6+
await new Promise(async resolve => {
7+
const mockContext = {
8+
user: 123,
9+
}
10+
11+
const expectedValuesByCallNumber = [
12+
undefined, // beforeSync 1
13+
'bar', // beforeSync 2
14+
'bar', // beforeSync 3
15+
]
16+
let callNumber = 0
17+
18+
const server = await newHocuspocus({
19+
async onConnect() {
20+
return mockContext
21+
},
22+
async beforeSync({ document, context, payload }) {
23+
t.deepEqual(context, mockContext)
24+
25+
const value = document.getArray('foo').get(0)
26+
27+
t.is(value, expectedValuesByCallNumber[callNumber])
28+
callNumber += 1
29+
30+
if (callNumber === expectedValuesByCallNumber.length - 1) {
31+
resolve('done')
32+
}
33+
},
34+
async onChange({ context, document }) {
35+
t.deepEqual(context, mockContext)
36+
37+
const value = document.getArray('foo').get(0)
38+
39+
t.is(value, expectedValuesByCallNumber[2])
40+
},
41+
})
42+
43+
const provider = newHocuspocusProvider(server, {
44+
onSynced() {
45+
provider.document.getArray('foo').insert(0, ['bar'])
46+
},
47+
})
48+
})
49+
})
50+
51+
test('beforeSync callback is called for every sync', async t => {
52+
let onConnectCount = 0
53+
let updateCount = 0
54+
let syncstep1Count = 0
55+
let syncstep2Count = 0
56+
57+
await new Promise(async resolve => {
58+
const server = await newHocuspocus({
59+
async onConnect() {
60+
onConnectCount += 1
61+
},
62+
async beforeSync({ type }) {
63+
if (type === 0){
64+
syncstep1Count += 1
65+
} else if (type === 1) {
66+
syncstep2Count += 1
67+
} else if (type === 2) {
68+
updateCount += 1
69+
}
70+
},
71+
})
72+
73+
await Promise.all([
74+
new Promise(done => {
75+
newHocuspocusProvider(server, {
76+
onClose() {
77+
t.fail()
78+
},
79+
onSynced() {
80+
done('done')
81+
},
82+
})
83+
}),
84+
new Promise(done => {
85+
newHocuspocusProvider(server, {
86+
onClose() {
87+
t.fail()
88+
},
89+
onSynced() {
90+
done('done')
91+
},
92+
})
93+
}),
94+
])
95+
96+
resolve('done')
97+
})
98+
99+
await retryableAssertion(t, tt => {
100+
tt.is(onConnectCount, 2)
101+
tt.is(syncstep1Count, 2)
102+
tt.is(syncstep2Count, 2)
103+
tt.is(updateCount, 0)
104+
})
105+
})
106+
107+
108+
test('beforeSync callback is called on every update', async t => {
109+
let onConnectCount = 0
110+
let updateCount = 0
111+
let syncstep1Count = 0
112+
let syncstep2Count = 0
113+
114+
115+
await new Promise(async resolve => {
116+
const server = await newHocuspocus({
117+
async onConnect() {
118+
onConnectCount += 1
119+
},
120+
async beforeSync({ type }) {
121+
if (type === 0){
122+
syncstep1Count += 1
123+
} else if (type === 1) {
124+
syncstep2Count += 1
125+
} else if (type === 2) {
126+
updateCount += 1
127+
}
128+
},
129+
})
130+
131+
await Promise.all([
132+
new Promise(done => {
133+
newHocuspocusProvider(server, {
134+
onClose() {
135+
t.fail()
136+
},
137+
onSynced() {
138+
done('done')
139+
},
140+
})
141+
}),
142+
new Promise(done => {
143+
const provider = newHocuspocusProvider(server, {
144+
onClose() {
145+
t.fail()
146+
},
147+
onSynced() {
148+
provider.document.getArray('foo').insert(0, ['bar'])
149+
done('done')
150+
},
151+
})
152+
}),
153+
])
154+
155+
resolve('done')
156+
})
157+
158+
await retryableAssertion(t, tt => {
159+
tt.is(onConnectCount, 2)
160+
tt.is(syncstep1Count, 2)
161+
tt.is(syncstep2Count, 2)
162+
tt.is(updateCount, 1)
163+
})
164+
})

0 commit comments

Comments
 (0)