snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
61
tests/server/address.ts
Normal file
61
tests/server/address.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from 'ava'
|
||||
import { Server } from '@hocuspocus/server'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
test('returns a dynamic HTTP/WebSocket address with the correct port', async t => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
port: 4010,
|
||||
})
|
||||
|
||||
t.is(hocuspocus.server!.address.port, 4010)
|
||||
t.is(hocuspocus.server!.httpURL, 'http://0.0.0.0:4010')
|
||||
t.is(hocuspocus.server!.webSocketURL, 'ws://0.0.0.0:4010')
|
||||
|
||||
t.pass()
|
||||
})
|
||||
|
||||
test('binds to the configured address', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
address: '127.0.0.1',
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
await server.listen()
|
||||
|
||||
const addressInfo = server.httpServer.address()
|
||||
t.truthy(addressInfo && typeof addressInfo === 'object')
|
||||
if (addressInfo && typeof addressInfo === 'object') {
|
||||
t.is(addressInfo.address, '127.0.0.1')
|
||||
t.is(addressInfo.family, 'IPv4')
|
||||
}
|
||||
|
||||
t.is(server.httpURL, `http://127.0.0.1:${server.address.port}`)
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('binds to all interfaces when no address is configured', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
await server.listen()
|
||||
|
||||
const addressInfo = server.httpServer.address()
|
||||
t.truthy(addressInfo && typeof addressInfo === 'object')
|
||||
if (addressInfo && typeof addressInfo === 'object') {
|
||||
t.true(['::', '0.0.0.0'].includes(addressInfo.address))
|
||||
}
|
||||
|
||||
t.is(server.httpURL, `http://0.0.0.0:${server.address.port}`)
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
102
tests/server/afterHandleMessage.ts
Normal file
102
tests/server/afterHandleMessage.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('afterHandleMessage is called after the update has been applied', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async afterHandleMessage({ document }) {
|
||||
// contrary to beforeHandleMessage, the update is already applied here
|
||||
if (document.getArray('foo').get(0) === 'bar') {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('afterHandleMessage is called once per handled message', async t => {
|
||||
let beforeHandleMessageCount = 0
|
||||
let afterHandleMessageCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeHandleMessage() {
|
||||
beforeHandleMessageCount += 1
|
||||
},
|
||||
async afterHandleMessage() {
|
||||
afterHandleMessageCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
})
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
})
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.true(afterHandleMessageCount > 0)
|
||||
tt.is(afterHandleMessageCount, beforeHandleMessageCount)
|
||||
})
|
||||
})
|
||||
|
||||
test('afterHandleMessage is not called when beforeHandleMessage rejects', async t => {
|
||||
let afterHandleMessageCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeHandleMessage() {
|
||||
throw new Error()
|
||||
},
|
||||
async afterHandleMessage() {
|
||||
afterHandleMessageCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(afterHandleMessageCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('an exception thrown in afterHandleMessage does not close the connection', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async afterHandleMessage() {
|
||||
throw new Error()
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
onSynced() {
|
||||
t.pass()
|
||||
setTimeout(() => resolve('done'), 100)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
65
tests/server/afterLoadDocument.ts
Normal file
65
tests/server/afterLoadDocument.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'ava'
|
||||
import type { HocuspocusProvider } from '@hocuspocus/provider'
|
||||
|
||||
import { newHocuspocus, newHocuspocusProvider, sleep } from '../utils/index.ts'
|
||||
|
||||
test('executes the afterLoadDocument callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async afterLoadDocument() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the afterLoadDocument callback in an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let provider: HocuspocusProvider
|
||||
|
||||
class CustomExtension {
|
||||
async afterLoadDocument() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('does not execute the afterLoadDocument callback when document fails to load', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
class CustomExtension {
|
||||
async onLoadDocument() {
|
||||
throw new Error('oops!')
|
||||
}
|
||||
|
||||
async afterLoadDocument() {
|
||||
t.fail('this should not be executed')
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
server.configure({
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
|
||||
await sleep(300)
|
||||
t.pass()
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
47
tests/server/afterStoreDocument.ts
Normal file
47
tests/server/afterStoreDocument.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('calls the afterStoreDocument hook', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async afterStoreDocument() {
|
||||
t.pass()
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray('foo').push(['foo'])
|
||||
provider.configuration.websocketProvider.destroy()
|
||||
provider.destroy()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes afterStoreDocument callback from a custom extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async afterStoreDocument() {
|
||||
t.pass()
|
||||
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
77
tests/server/afterUnloadDocument.ts
Normal file
77
tests/server/afterUnloadDocument.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import test from 'ava'
|
||||
import type { HocuspocusProvider } from '@hocuspocus/provider'
|
||||
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('executes the afterUnloadDocument callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async afterUnloadDocument() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const p = newHocuspocusProvider(t, server, {
|
||||
onSynced(data) {
|
||||
p.configuration.websocketProvider.disconnect()
|
||||
p.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the afterUnloadDocument callback when all clients disconnect after a document was loaded', async t => {
|
||||
await new Promise(async resolve => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let provider: HocuspocusProvider
|
||||
|
||||
class CustomExtension {
|
||||
async afterLoadDocument() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
}
|
||||
|
||||
async afterUnloadDocument() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
})
|
||||
|
||||
provider = newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('does not execute the afterUnloadDocument callback when document fails to load', async t => {
|
||||
await new Promise(async (resolve, reject) => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
class CustomExtension {
|
||||
async onLoadDocument() {
|
||||
throw new Error('oops!')
|
||||
}
|
||||
|
||||
async afterUnloadDocument() {
|
||||
t.fail()
|
||||
reject('should not be called')
|
||||
}
|
||||
}
|
||||
|
||||
server.configure({
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
|
||||
setTimeout(() => {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
21
tests/server/beforeBroadcastStateless.ts
Normal file
21
tests/server/beforeBroadcastStateless.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('calls the beforeBroadcastStateless hook', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const payloadToSend = 'STATELESS-MESSAGE'
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeBroadcastStateless({ payload }) {
|
||||
t.is(payload, payloadToSend)
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
server.documents.get('hocuspocus-test')?.broadcastStateless(payloadToSend)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
239
tests/server/beforeHandleAwareness.ts
Normal file
239
tests/server/beforeHandleAwareness.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('beforeHandleAwareness is called before the awareness state is applied', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeHandleAwareness({ awareness, states, connection }) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
// The decoded states from the inbound update are exposed as a
|
||||
// mutable Map keyed by clientId. The document's own awareness has
|
||||
// not received the update yet.
|
||||
t.true(states instanceof Map)
|
||||
t.true(states.size > 0)
|
||||
t.truthy(connection, 'connection is forwarded per call')
|
||||
t.is(awareness.getStates().size, 0)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('mutating states in beforeHandleAwareness rewrites the update before it is applied', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeHandleAwareness({ states }) {
|
||||
// Stamp a server-side value over whatever the client sent.
|
||||
for (const state of states.values()) {
|
||||
state.user = 'Verified'
|
||||
}
|
||||
},
|
||||
async onAwarenessUpdate({ states }) {
|
||||
const v = states.find(s => s.user === 'Verified' && s.foo === 'bar')
|
||||
if (resolved || !v) return
|
||||
resolved = true
|
||||
|
||||
// Mutated value lands in awareness; original ('Spoofed') never appears.
|
||||
t.is(v.user, 'Verified')
|
||||
t.is(v.foo, 'bar')
|
||||
t.falsy(states.find(s => s.user === 'Spoofed'))
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('user', 'Spoofed')
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('chaining: a second extension sees mutations made by the first', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
class FirstExtension {
|
||||
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
|
||||
for (const state of states.values()) {
|
||||
state.user = 'first'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecondExtension {
|
||||
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
|
||||
// Sees `first`'s mutation, appends to it.
|
||||
for (const state of states.values()) {
|
||||
t.is(state.user, 'first', 'sees first extension mutation')
|
||||
state.user = `${state.user}-then-second`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new FirstExtension(), new SecondExtension()],
|
||||
async onAwarenessUpdate({ states }) {
|
||||
const v = states.find(s => s.user === 'first-then-second')
|
||||
if (resolved || !v) return
|
||||
resolved = true
|
||||
|
||||
// Both extension mutations made it through, in order.
|
||||
t.is(v.user, 'first-then-second')
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('user', 'unchanged-by-client')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('chaining: extensions run before the config-level hook, in registration order', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
class ExtensionA {
|
||||
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
|
||||
for (const state of states.values()) {
|
||||
state.trail = `${state.trail ?? ''}A`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionB {
|
||||
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
|
||||
for (const state of states.values()) {
|
||||
state.trail = `${state.trail ?? ''}B`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new ExtensionA(), new ExtensionB()],
|
||||
async beforeHandleAwareness({ states }) {
|
||||
// Config-level hook is pushed onto `extensions` as the final entry by
|
||||
// `configure()`, so it runs AFTER both class extensions.
|
||||
for (const state of states.values()) {
|
||||
state.trail = `${state.trail ?? ''}C`
|
||||
}
|
||||
},
|
||||
async onAwarenessUpdate({ states }) {
|
||||
const v = states.find(s => s.trail === 'ABC')
|
||||
if (resolved || !v) return
|
||||
resolved = true
|
||||
|
||||
t.is(v.trail, 'ABC', 'extensions run first then the config-level hook')
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('init', true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('throwing discards preceding extension mutations', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let mutatorCalls = 0
|
||||
let throwerCalls = 0
|
||||
let onUpdateCalls = 0
|
||||
|
||||
class MutatingExtension {
|
||||
async beforeHandleAwareness({ states }: { states: Map<number, Record<string, any>> }) {
|
||||
mutatorCalls += 1
|
||||
for (const state of states.values()) {
|
||||
state.user = 'first'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingExtension {
|
||||
async beforeHandleAwareness() {
|
||||
throwerCalls += 1
|
||||
throw new Error('rejected')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new MutatingExtension(), new ThrowingExtension()],
|
||||
async onAwarenessUpdate({ states }) {
|
||||
if (states.length > 0) onUpdateCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
t.true(mutatorCalls >= 1, 'preceding extension fired')
|
||||
t.true(throwerCalls >= 1, 'throwing extension fired')
|
||||
t.is(onUpdateCalls, 0, 'preceding mutations are not half-applied')
|
||||
resolve('done')
|
||||
}, 400)
|
||||
})
|
||||
})
|
||||
|
||||
test('throwing aborts subsequent extensions and the config-level hook', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let throwerCalls = 0
|
||||
let afterThrowerCalls = 0
|
||||
let configCalls = 0
|
||||
|
||||
class ThrowingExtension {
|
||||
async beforeHandleAwareness() {
|
||||
throwerCalls += 1
|
||||
throw new Error('rejected')
|
||||
}
|
||||
}
|
||||
|
||||
class AfterThrowerExtension {
|
||||
async beforeHandleAwareness() {
|
||||
afterThrowerCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new ThrowingExtension(), new AfterThrowerExtension()],
|
||||
async beforeHandleAwareness() {
|
||||
configCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
t.true(throwerCalls >= 1, 'throwing extension fired')
|
||||
t.is(afterThrowerCalls, 0, 'extensions after the throw do not run')
|
||||
t.is(configCalls, 0, 'config-level hook does not run')
|
||||
resolve('done')
|
||||
}, 400)
|
||||
})
|
||||
})
|
||||
111
tests/server/beforeHandleMessage.ts
Normal file
111
tests/server/beforeHandleMessage.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('beforeHandleMessage gets called in proper order', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
}
|
||||
|
||||
const expectedValuesByCallNumber = [
|
||||
undefined, // syncstep1
|
||||
undefined, // syncstep2
|
||||
'foo', // sync finished, value should be there now
|
||||
]
|
||||
let callNumber = 0
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
return mockContext
|
||||
},
|
||||
async beforeHandleMessage({ document, context }) {
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
const value = document.getArray('foo').get(0)
|
||||
|
||||
t.is(value, expectedValuesByCallNumber[callNumber])
|
||||
callNumber += 1
|
||||
|
||||
if (callNumber === expectedValuesByCallNumber.length - 1) {
|
||||
resolve('done')
|
||||
}
|
||||
},
|
||||
async onChange({ context, document }) {
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
const value = document.getArray('foo').get(0)
|
||||
|
||||
t.is(value, expectedValuesByCallNumber[2])
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('beforeHandleMessage callback is called for every new client', async t => {
|
||||
let onConnectCount = 0
|
||||
let beforeHandleMessageCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
onConnectCount += 1
|
||||
},
|
||||
async beforeHandleMessage() {
|
||||
beforeHandleMessageCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
})
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
})
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(onConnectCount, 2)
|
||||
tt.is(beforeHandleMessageCount, 6) // 2x awareness per conn, 2x sync per conn (step 1 + 2)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test('an exception thrown in beforeHandleMessage closes the connection and discards queued messages', async t => {
|
||||
let beforeHandleMessageCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeHandleMessage({ connection, update }) {
|
||||
beforeHandleMessageCount += 1
|
||||
|
||||
if (beforeHandleMessageCount === 1) {
|
||||
connection.handleMessage(update)
|
||||
}
|
||||
|
||||
throw new Error()
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
t.is(beforeHandleMessageCount, 1)
|
||||
})
|
||||
162
tests/server/beforeSync.ts
Normal file
162
tests/server/beforeSync.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('beforeSync gets called in proper order', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
}
|
||||
|
||||
let callNumber = 0
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
return mockContext
|
||||
},
|
||||
async beforeSync({ document, context, payload }) {
|
||||
if (resolved) return
|
||||
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
callNumber += 1
|
||||
|
||||
if (callNumber === 2) {
|
||||
resolved = true
|
||||
resolve('done')
|
||||
}
|
||||
},
|
||||
async onChange({ context, document }) {
|
||||
if (resolved) return
|
||||
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
const value = document.getArray('foo').get(0)
|
||||
|
||||
t.is(value, 'bar')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('beforeSync callback is called for every sync', async t => {
|
||||
let onConnectCount = 0
|
||||
let updateCount = 0
|
||||
let syncstep1Count = 0
|
||||
let syncstep2Count = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
onConnectCount += 1
|
||||
},
|
||||
async beforeSync({ type }) {
|
||||
if (type === 0){
|
||||
syncstep1Count += 1
|
||||
} else if (type === 1) {
|
||||
syncstep2Count += 1
|
||||
} else if (type === 2) {
|
||||
updateCount += 1
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
new Promise(done => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
onSynced() {
|
||||
done('done')
|
||||
},
|
||||
})
|
||||
}),
|
||||
new Promise(done => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
onSynced() {
|
||||
done('done')
|
||||
},
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(onConnectCount, 2)
|
||||
tt.is(syncstep1Count, 2)
|
||||
tt.is(syncstep2Count, 2)
|
||||
tt.is(updateCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
test('beforeSync callback is called on every update', async t => {
|
||||
let onConnectCount = 0
|
||||
let updateCount = 0
|
||||
let syncstep1Count = 0
|
||||
let syncstep2Count = 0
|
||||
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
onConnectCount += 1
|
||||
},
|
||||
async beforeSync({ type }) {
|
||||
if (type === 0){
|
||||
syncstep1Count += 1
|
||||
} else if (type === 1) {
|
||||
syncstep2Count += 1
|
||||
} else if (type === 2) {
|
||||
updateCount += 1
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
new Promise(done => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
onSynced() {
|
||||
done('done')
|
||||
},
|
||||
})
|
||||
}),
|
||||
new Promise(done => {
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail()
|
||||
},
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
done('done')
|
||||
},
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(onConnectCount, 2)
|
||||
tt.is(syncstep1Count, 2)
|
||||
tt.is(syncstep2Count, 2)
|
||||
tt.is(updateCount, 1)
|
||||
})
|
||||
})
|
||||
81
tests/server/beforeUnloadDocument.ts
Normal file
81
tests/server/beforeUnloadDocument.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import test from "ava";
|
||||
|
||||
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
|
||||
|
||||
test("executes the beforeUnloadDocument callback", async (t) => {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async beforeUnloadDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const p = newHocuspocusProvider(t, server, {
|
||||
onSynced(data) {
|
||||
p.destroy();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("executes the beforeUnloadDocument callback when all clients disconnect after a document was loaded", async (t) => {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
await new Promise(async (resolve) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let provider: HocuspocusProvider;
|
||||
|
||||
class CustomExtension {
|
||||
async afterLoadDocument() {
|
||||
provider.destroy();
|
||||
}
|
||||
|
||||
async beforeUnloadDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
provider = newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("throwing an exception in beforeUnloadDocument prevents a document from being unloaded", async (t) => {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
||||
await new Promise(async (resolve) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let provider: HocuspocusProvider;
|
||||
|
||||
class CustomExtension {
|
||||
async beforeUnloadDocument() {
|
||||
throw new Error("my custom error");
|
||||
}
|
||||
|
||||
async afterUnloadDocument() {
|
||||
t.fail("should not be called");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
const p = newHocuspocusProvider(t, server, {
|
||||
onSynced(data) {
|
||||
p.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
t.is(server.documents.size, 1);
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
153
tests/server/broadcastEncoding.ts
Normal file
153
tests/server/broadcastEncoding.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from 'ava'
|
||||
import { makeRoutingKey } from '@hocuspocus/common'
|
||||
import { Document } from '@hocuspocus/server'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
|
||||
type FakeConnection = {
|
||||
messageAddress: string
|
||||
sent: Uint8Array[]
|
||||
send: (message: Uint8Array) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Encoding is identical batched or not, so these tests opt out of batching to
|
||||
* keep every assertion synchronous.
|
||||
*/
|
||||
const newDocument = () => new Document('hocuspocus-test', undefined, { flushDelay: false })
|
||||
|
||||
/**
|
||||
* Registers a minimal stand-in for a Connection. The broadcast paths only read
|
||||
* `messageAddress` and call `send`, so a duck-typed object is enough to observe
|
||||
* what they produce.
|
||||
*/
|
||||
const addConnection = (document: Document, messageAddress: string): FakeConnection => {
|
||||
const connection: FakeConnection = {
|
||||
messageAddress,
|
||||
sent: [],
|
||||
send(message: Uint8Array) {
|
||||
this.sent.push(message)
|
||||
},
|
||||
}
|
||||
|
||||
document.connections.set(connection as any, { clients: new Set() })
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
const readAddress = (message: Uint8Array) => decoding.readVarString(decoding.createDecoder(message))
|
||||
|
||||
test('encodes a document update once and shares it across connections', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const first = addConnection(document, 'hocuspocus-test')
|
||||
const second = addConnection(document, 'hocuspocus-test')
|
||||
const third = addConnection(document, 'hocuspocus-test')
|
||||
|
||||
document.getMap('test').set('foo', 'bar')
|
||||
|
||||
t.is(first.sent.length, 1)
|
||||
t.is(second.sent.length, 1)
|
||||
t.is(third.sent.length, 1)
|
||||
|
||||
// The same buffer instance reaches every connection, so it was encoded once.
|
||||
t.is(first.sent[0], second.sent[0])
|
||||
t.is(first.sent[0], third.sent[0])
|
||||
})
|
||||
|
||||
test('encodes an awareness update once and shares it across connections', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const first = addConnection(document, 'hocuspocus-test')
|
||||
const second = addConnection(document, 'hocuspocus-test')
|
||||
|
||||
document.awareness.setLocalState({ foo: 'bar' })
|
||||
|
||||
t.is(first.sent.length, 1)
|
||||
t.is(second.sent.length, 1)
|
||||
t.is(first.sent[0], second.sent[0])
|
||||
})
|
||||
|
||||
test('encodes a stateless broadcast once and shares it across connections', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const first = addConnection(document, 'hocuspocus-test')
|
||||
const second = addConnection(document, 'hocuspocus-test')
|
||||
|
||||
document.broadcastStateless('{"event":"document.saved"}')
|
||||
|
||||
t.is(first.sent.length, 1)
|
||||
t.is(second.sent.length, 1)
|
||||
t.is(first.sent[0], second.sent[0])
|
||||
})
|
||||
|
||||
test('a filtered stateless broadcast only reaches matching connections', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const first = addConnection(document, 'hocuspocus-test')
|
||||
const skipped = addConnection(document, 'hocuspocus-test')
|
||||
const third = addConnection(document, 'hocuspocus-test')
|
||||
|
||||
document.broadcastStateless('{"event":"document.saved"}', connection => connection !== (skipped as any))
|
||||
|
||||
t.is(first.sent.length, 1)
|
||||
t.is(skipped.sent.length, 0)
|
||||
t.is(third.sent.length, 1)
|
||||
|
||||
// A skipped connection in the middle must not cost the others their shared buffer.
|
||||
t.is(first.sent[0], third.sent[0])
|
||||
})
|
||||
|
||||
test('addresses stateless broadcasts per connection', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const legacy = addConnection(document, 'hocuspocus-test')
|
||||
const session = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
|
||||
|
||||
document.broadcastStateless('{"event":"document.saved"}')
|
||||
|
||||
t.is(readAddress(legacy.sent[0]), 'hocuspocus-test')
|
||||
t.is(readAddress(session.sent[0]), makeRoutingKey('hocuspocus-test', 'session-a'))
|
||||
t.not(legacy.sent[0], session.sent[0])
|
||||
})
|
||||
|
||||
test('reuses the buffer across consecutive connections sharing an address', t => {
|
||||
const document = newDocument()
|
||||
|
||||
const legacy = addConnection(document, 'hocuspocus-test')
|
||||
const sessionA = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
|
||||
const sessionAToo = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a'))
|
||||
const sessionB = addConnection(document, makeRoutingKey('hocuspocus-test', 'session-b'))
|
||||
|
||||
document.getMap('test').set('foo', 'bar')
|
||||
|
||||
t.is(sessionA.sent[0], sessionAToo.sent[0])
|
||||
t.not(legacy.sent[0], sessionA.sent[0])
|
||||
t.not(sessionA.sent[0], sessionB.sent[0])
|
||||
|
||||
const buffers = new Set([legacy.sent[0], sessionA.sent[0], sessionAToo.sent[0], sessionB.sent[0]])
|
||||
t.is(buffers.size, 3)
|
||||
})
|
||||
|
||||
test('addresses every broadcast message to the receiving connection', t => {
|
||||
const document = newDocument()
|
||||
|
||||
// Interleaved addresses: buffer reuse does not apply here, so this also
|
||||
// covers the fallback path where every connection gets its own encode.
|
||||
const connections = [
|
||||
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a')),
|
||||
addConnection(document, 'hocuspocus-test'),
|
||||
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-a')),
|
||||
addConnection(document, makeRoutingKey('hocuspocus-test', 'session-b')),
|
||||
]
|
||||
|
||||
document.getMap('test').set('foo', 'bar')
|
||||
document.awareness.setLocalState({ foo: 'bar' })
|
||||
|
||||
for (const connection of connections) {
|
||||
t.is(connection.sent.length, 2)
|
||||
|
||||
for (const message of connection.sent) {
|
||||
t.is(readAddress(message), connection.messageAddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
104
tests/server/broadcastStatelessBypass.ts
Normal file
104
tests/server/broadcastStatelessBypass.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import test from 'ava'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { writeAuthentication } from '@hocuspocus/common'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
|
||||
const MessageType = {
|
||||
Auth: 2,
|
||||
Stateless: 5,
|
||||
BroadcastStateless: 6,
|
||||
}
|
||||
|
||||
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, MessageType.Auth)
|
||||
writeAuthentication(encoder, token)
|
||||
// providerVersion is optional on the server side, omit it.
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
const buildBroadcastStatelessFrame = (documentName: string, payload: string): Uint8Array => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, MessageType.BroadcastStateless)
|
||||
encoding.writeVarString(encoder, payload)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.addEventListener('open', () => resolve(ws), { once: true })
|
||||
ws.addEventListener('error', (event) => reject(event), { once: true })
|
||||
})
|
||||
|
||||
test('rejects client-sent BroadcastStateless (opcode 6) — payload must not reach peers', async t => {
|
||||
const documentName = 'hocuspocus-test'
|
||||
const attackerPayload = '{"spoof":"server-system-banner","marker":"LITMUS"}'
|
||||
|
||||
let onStatelessCalls = 0
|
||||
let beforeBroadcastStatelessCalls = 0
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token, connectionConfig }) {
|
||||
if (token === 'readonly') {
|
||||
connectionConfig.readOnly = true
|
||||
}
|
||||
},
|
||||
async onStateless() {
|
||||
onStatelessCalls += 1
|
||||
},
|
||||
async beforeBroadcastStateless() {
|
||||
beforeBroadcastStatelessCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
const received: string[] = []
|
||||
const victimReady = new Promise<void>((resolve) => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: documentName,
|
||||
token: 'read+write',
|
||||
onSynced: () => resolve(),
|
||||
onStateless: ({ payload }) => {
|
||||
received.push(payload)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await victimReady
|
||||
|
||||
// Attacker: raw WebSocket using a readOnly token. Sends a single
|
||||
// BroadcastStateless (opcode 6) frame after a successful auth. The
|
||||
// server must reject this server-internal opcode and not fan it out.
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
const authenticated = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('auth timeout')), 5000)
|
||||
attacker.addEventListener('message', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
attacker.send(buildAuthFrame(documentName, 'readonly'))
|
||||
await authenticated
|
||||
|
||||
attacker.send(buildBroadcastStatelessFrame(documentName, attackerPayload))
|
||||
|
||||
// Give the server time to (incorrectly) fan out, if the bug is present.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
t.false(
|
||||
received.includes(attackerPayload),
|
||||
'victim must not receive payloads from client-sent BroadcastStateless frames',
|
||||
)
|
||||
t.is(onStatelessCalls, 0, 'onStateless must not fire for opcode 6 (it is not the Stateless opcode)')
|
||||
t.is(
|
||||
beforeBroadcastStatelessCalls,
|
||||
0,
|
||||
'beforeBroadcastStateless must not fire because the frame is rejected before fan-out',
|
||||
)
|
||||
})
|
||||
85
tests/server/closeConnections.ts
Normal file
85
tests/server/closeConnections.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'ava'
|
||||
import { WebSocketStatus } from '@hocuspocus/provider'
|
||||
import {
|
||||
newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket, sleep,
|
||||
} from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
// test('closes all connections', async t => {
|
||||
// const server = await newHocuspocus(t)
|
||||
// const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
// const socket2 = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
// const provider = newHocuspocusProvider(t, server, {
|
||||
// name: 'hocuspocus-test',
|
||||
// onClose() {
|
||||
// // Make sure it doesn’t reconnect.
|
||||
// socket.disconnect()
|
||||
// },
|
||||
// websocketProvider: socket,
|
||||
// })
|
||||
|
||||
// const anotherProvider = newHocuspocusProvider(t, server, {
|
||||
// name: 'hocuspocus-test-2',
|
||||
// onClose() {
|
||||
// // Make sure it doesn’t reconnect.
|
||||
// socket2.disconnect()
|
||||
// },
|
||||
// websocketProvider: socket2,
|
||||
// })
|
||||
|
||||
// await sleep(100)
|
||||
|
||||
// server.closeConnections()
|
||||
|
||||
// t.is(server.documents.size, 1)
|
||||
// })
|
||||
|
||||
test('closes a specific connection when a documentName is passed', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
const socket2 = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
name: 'hocuspocus-test',
|
||||
onClose() {
|
||||
// Make sure it doesn’t reconnect.
|
||||
socket.disconnect()
|
||||
},
|
||||
websocketProvider: socket,
|
||||
})
|
||||
|
||||
const anotherProvider = newHocuspocusProvider(t, server, {
|
||||
name: 'hocuspocus-test-2',
|
||||
websocketProvider: socket2,
|
||||
})
|
||||
|
||||
await sleep(100)
|
||||
|
||||
server.closeConnections('hocuspocus-test')
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(socket.status, WebSocketStatus.Disconnected)
|
||||
tt.is(socket2.status, WebSocketStatus.Connected)
|
||||
})
|
||||
})
|
||||
|
||||
// test('uses a proper close event', async t => {
|
||||
// await new Promise(async resolve => {
|
||||
// const server = await newHocuspocus(t)
|
||||
|
||||
// newHocuspocusProvider(t, server, {
|
||||
// name: 'hocuspocus-test',
|
||||
// onSynced() {
|
||||
// server.closeConnections()
|
||||
// },
|
||||
// onClose({ event }) {
|
||||
// // Make sure it doesn’t reconnect.
|
||||
// t.is(event.code, 1000)
|
||||
// t.is(event.reason, 'Reset Connection')
|
||||
|
||||
// resolve('done')
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
31
tests/server/debounce.ts
Normal file
31
tests/server/debounce.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useDebounce } from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import { sleep } from "../utils/index.ts";
|
||||
|
||||
test("maxDebounce does not queue an execution per call while a slow execution is in flight", async (t) => {
|
||||
const { debounce } = useDebounce();
|
||||
|
||||
let executions = 0;
|
||||
const func = async () => {
|
||||
executions += 1;
|
||||
// the first execution is slower than maxDebounce, simulating a
|
||||
// database/S3 latency spike during a store
|
||||
await sleep(executions === 1 ? 500 : 10);
|
||||
};
|
||||
|
||||
// Continuous calls, like a busy collaborative document: each call resets
|
||||
// the debounce timeout, so only the maxDebounce branch triggers executions
|
||||
// (at most one per 200ms).
|
||||
const end = Date.now() + 1_000;
|
||||
while (Date.now() < end) {
|
||||
debounce("doc", func, 50, 200);
|
||||
await sleep(20);
|
||||
}
|
||||
await sleep(600); // let in-flight and queued executions finish
|
||||
|
||||
// ~1s of continuous calls with maxDebounce=200 should execute ~5 times.
|
||||
// If run() suspends on the in-flight execution before deleting the timer
|
||||
// entry, the stale entry keeps satisfying the maxDebounce age check and
|
||||
// every call during the slow execution queues another run (~25 executions).
|
||||
t.true(executions <= 8, `executed ${executions} times, expected ~5`);
|
||||
});
|
||||
27
tests/server/destroy.ts
Normal file
27
tests/server/destroy.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "ava";
|
||||
import { Server } from "@hocuspocus/server";
|
||||
|
||||
test("destroy only runs once when called multiple times", async (t) => {
|
||||
let destroyed = 0;
|
||||
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
quiet: true,
|
||||
stopOnSignals: false,
|
||||
async onDestroy() {
|
||||
destroyed += 1;
|
||||
},
|
||||
});
|
||||
|
||||
t.teardown(() => server.httpServer.close());
|
||||
|
||||
await server.listen();
|
||||
|
||||
// Concurrent and subsequent calls (e.g. from repeated SIGINT signals)
|
||||
// must not re-run the shutdown, which would close already-closed
|
||||
// resources in extensions.
|
||||
await Promise.all([server.destroy(), server.destroy()]);
|
||||
await server.destroy();
|
||||
|
||||
t.is(destroyed, 1);
|
||||
});
|
||||
268
tests/server/flushDelay.ts
Normal file
268
tests/server/flushDelay.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import test from 'ava'
|
||||
import { Document } from '@hocuspocus/server'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as Y from 'yjs'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
type FakeConnection = {
|
||||
messageAddress: string
|
||||
sent: Uint8Array[]
|
||||
send: (message: Uint8Array) => void
|
||||
}
|
||||
|
||||
const addConnection = (document: Document): FakeConnection => {
|
||||
const connection: FakeConnection = {
|
||||
messageAddress: document.name,
|
||||
sent: [],
|
||||
send(message: Uint8Array) {
|
||||
this.sent.push(message)
|
||||
},
|
||||
}
|
||||
|
||||
document.connections.set(connection as any, { clients: new Set() })
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
/** Resolves after the flush scheduled with `flushDelay: 0` has run. */
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
const readUpdate = (message: Uint8Array) => {
|
||||
const decoder = decoding.createDecoder(message)
|
||||
|
||||
decoding.readVarString(decoder) // message address
|
||||
decoding.readVarUint(decoder) // MessageType.Sync
|
||||
decoding.readVarUint(decoder) // messageYjsUpdate
|
||||
|
||||
return decoding.readVarUint8Array(decoder)
|
||||
}
|
||||
|
||||
const readAwarenessStates = (message: Uint8Array) => {
|
||||
const decoder = decoding.createDecoder(message)
|
||||
|
||||
decoding.readVarString(decoder) // message address
|
||||
decoding.readVarUint(decoder) // MessageType.Awareness
|
||||
|
||||
const payload = decoding.createDecoder(decoding.readVarUint8Array(decoder))
|
||||
const states = new Map<number, any>()
|
||||
const count = decoding.readVarUint(payload)
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const clientId = decoding.readVarUint(payload)
|
||||
|
||||
decoding.readVarUint(payload) // clock
|
||||
states.set(clientId, JSON.parse(decoding.readVarString(payload)))
|
||||
}
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
test('sends one message per change when flushDelay is false', t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: false })
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
document.getMap('test').set('c', 3)
|
||||
|
||||
t.is(connection.sent.length, 3)
|
||||
})
|
||||
|
||||
test('batches by default', async t => {
|
||||
const document = new Document('hocuspocus-test')
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
|
||||
t.is(document.flushDelay, 0)
|
||||
t.is(connection.sent.length, 0)
|
||||
|
||||
await tick()
|
||||
|
||||
t.is(connection.sent.length, 1)
|
||||
})
|
||||
|
||||
test('a server batches by default', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
t.is(server.configuration.flushDelay, 0)
|
||||
})
|
||||
|
||||
test('merges updates from the same tick into a single message', async t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
document.getMap('test').set('c', 3)
|
||||
|
||||
// Nothing goes out synchronously; the flush is scheduled for the end of the turn.
|
||||
t.is(connection.sent.length, 0)
|
||||
|
||||
await tick()
|
||||
|
||||
t.is(connection.sent.length, 1)
|
||||
})
|
||||
|
||||
test('the merged message carries every buffered change', async t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
document.getMap('test').set('c', 3)
|
||||
|
||||
await tick()
|
||||
|
||||
const target = new Y.Doc()
|
||||
|
||||
Y.applyUpdate(target, readUpdate(connection.sent[0]))
|
||||
|
||||
const map = target.getMap('test')
|
||||
|
||||
t.is(map.get('a'), 1)
|
||||
t.is(map.get('b'), 2)
|
||||
t.is(map.get('c'), 3)
|
||||
})
|
||||
|
||||
test('collapses awareness changes to the latest state of each client', async t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.awareness.setLocalState({ cursor: 1 })
|
||||
document.awareness.setLocalState({ cursor: 2 })
|
||||
document.awareness.setLocalState({ cursor: 3 })
|
||||
|
||||
t.is(connection.sent.length, 0)
|
||||
|
||||
await tick()
|
||||
|
||||
t.is(connection.sent.length, 1)
|
||||
|
||||
const states = readAwarenessStates(connection.sent[0])
|
||||
|
||||
t.is(states.size, 1)
|
||||
t.is(states.get(document.clientID).cursor, 3)
|
||||
})
|
||||
|
||||
test('flushes early once flushMaxBytes is buffered', t => {
|
||||
const document = new Document('hocuspocus-test', undefined, {
|
||||
flushDelay: 0,
|
||||
flushMaxBytes: 1,
|
||||
})
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
|
||||
// Every update trips the limit, so each is sent without waiting for the flush.
|
||||
t.is(connection.sent.length, 2)
|
||||
})
|
||||
|
||||
test('calls onUpdate synchronously for every change even when batching', t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const connection = addConnection(document)
|
||||
|
||||
let updates = 0
|
||||
|
||||
document.callbacks.onUpdate = () => {
|
||||
updates += 1
|
||||
}
|
||||
|
||||
document.getMap('test').set('a', 1)
|
||||
document.getMap('test').set('b', 2)
|
||||
document.getMap('test').set('c', 3)
|
||||
|
||||
// The broadcast is deferred, the hook that drives onChange / onStoreDocument
|
||||
// and the Redis publish is not.
|
||||
t.is(updates, 3)
|
||||
t.is(connection.sent.length, 0)
|
||||
})
|
||||
|
||||
test('flush is a no-op when nothing is pending', t => {
|
||||
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const connection = addConnection(document)
|
||||
|
||||
document.flush()
|
||||
document.flush()
|
||||
|
||||
t.is(connection.sent.length, 0)
|
||||
})
|
||||
|
||||
test('destroy flushes what is still buffered instead of dropping it', async t => {
|
||||
const batched = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
|
||||
const batchedConnection = addConnection(batched)
|
||||
|
||||
batched.getMap('test').set('a', 1)
|
||||
|
||||
t.is(batchedConnection.sent.length, 0)
|
||||
|
||||
batched.destroy()
|
||||
|
||||
// Everything is out by the time destroy returns, and nothing is scheduled
|
||||
// afterwards.
|
||||
const afterDestroy = batchedConnection.sent.length
|
||||
|
||||
await tick()
|
||||
|
||||
t.is(batchedConnection.sent.length, afterDestroy)
|
||||
|
||||
// The same document without batching emits exactly as many messages: the
|
||||
// buffered update plus the awareness teardown that destroy triggers.
|
||||
const unbatched = new Document('hocuspocus-test', undefined, { flushDelay: false })
|
||||
const unbatchedConnection = addConnection(unbatched)
|
||||
|
||||
unbatched.getMap('test').set('a', 1)
|
||||
unbatched.destroy()
|
||||
|
||||
t.is(batchedConnection.sent.length, unbatchedConnection.sent.length)
|
||||
})
|
||||
|
||||
test('a batching server still converges on connected clients', async t => {
|
||||
const server = await newHocuspocus(t, { flushDelay: 0 })
|
||||
|
||||
const writer = newHocuspocusProvider(t, server, { name: 'flush-converge' })
|
||||
const reader = newHocuspocusProvider(t, server, { name: 'flush-converge' })
|
||||
|
||||
await new Promise(resolve => reader.on('synced', () => resolve('done')))
|
||||
|
||||
writer.document.getMap('test').set('a', 1)
|
||||
writer.document.getMap('test').set('b', 2)
|
||||
writer.document.getMap('test').set('c', 3)
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
const map = reader.document.getMap('test')
|
||||
|
||||
tt.is(map.get('a'), 1)
|
||||
tt.is(map.get('b'), 2)
|
||||
tt.is(map.get('c'), 3)
|
||||
})
|
||||
})
|
||||
|
||||
test('onChange still fires per change when batching is enabled', async t => {
|
||||
let changes = 0
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
flushDelay: 0,
|
||||
async onChange() {
|
||||
changes += 1
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
name: 'flush-onchange',
|
||||
awareness: null,
|
||||
})
|
||||
|
||||
await new Promise(resolve => provider.on('synced', () => resolve('done')))
|
||||
|
||||
provider.document.getMap('test').set('a', 1)
|
||||
provider.document.getMap('test').set('b', 2)
|
||||
provider.document.getMap('test').set('c', 3)
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(changes, 3)
|
||||
})
|
||||
})
|
||||
130
tests/server/getConnectionsCount.ts
Normal file
130
tests/server/getConnectionsCount.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from 'ava'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
|
||||
|
||||
test('returns 0 connections when there’s no one connected', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
t.is(server.getConnectionsCount(), 0)
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
test('close connection open when it fails', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
throw new Error()
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.is(server.getConnectionsCount(), 0)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('dont close connection open when it fails but socket is external', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
throw new Error()
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.is(server.getConnectionsCount(), 0)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('outputs the total connections', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.is(server.getConnectionsCount(), 1)
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.is(server.getConnectionsCount(), 2)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('total connections includes direct connections', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, { name: 'hocuspocus-test' })
|
||||
|
||||
await server.openDirectConnection('hocuspocus-test')
|
||||
t.is(server.getConnectionsCount(), 1)
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.is(server.getConnectionsCount(), 2)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('adds and removes connections properly', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const providers = [
|
||||
newHocuspocusProvider(t, server),
|
||||
newHocuspocusProvider(t, server),
|
||||
newHocuspocusProvider(t, server),
|
||||
newHocuspocusProvider(t, server),
|
||||
newHocuspocusProvider(t, server),
|
||||
]
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 5)
|
||||
})
|
||||
|
||||
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('multiplexed connections counts properly', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
const providers = [
|
||||
newHocuspocusProvider(t, server, { name: 'mux-1' }, {}, socket),
|
||||
newHocuspocusProvider(t, server, { name: 'mux-2' }, {}, socket),
|
||||
newHocuspocusProvider(t, server, { name: 'mux-3' }, {}, socket),
|
||||
newHocuspocusProvider(t, server),
|
||||
newHocuspocusProvider(t, server),
|
||||
|
||||
]
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 3)
|
||||
})
|
||||
|
||||
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 0)
|
||||
})
|
||||
})
|
||||
93
tests/server/getDocumentsCount.ts
Normal file
93
tests/server/getDocumentsCount.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import test from 'ava'
|
||||
import {
|
||||
newHocuspocus, newHocuspocusProvider, randomInteger,
|
||||
} from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('documents count is zero by default', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
t.is(server.getDocumentsCount(), 0)
|
||||
})
|
||||
|
||||
test('documents count is 1 when one provider is connected', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.is(server.getDocumentsCount(), 1)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('the same document name counts as one document', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const providers = [
|
||||
newHocuspocusProvider(t, server, { name: 'foobar' }),
|
||||
newHocuspocusProvider(t, server, { name: 'foobar' }),
|
||||
]
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getDocumentsCount(), 1)
|
||||
})
|
||||
|
||||
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('adds and removes different documents properly', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const providers = [
|
||||
newHocuspocusProvider(t, server, { name: 'foo-1' }),
|
||||
newHocuspocusProvider(t, server, { name: 'foo-2' }),
|
||||
newHocuspocusProvider(t, server, { name: 'foo-3' }),
|
||||
newHocuspocusProvider(t, server, { name: 'foo-4' }),
|
||||
newHocuspocusProvider(t, server, { name: 'foo-5' }),
|
||||
]
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getDocumentsCount(), 5)
|
||||
})
|
||||
|
||||
providers.forEach(provider => { provider.disconnect(); provider.configuration.websocketProvider.disconnect() })
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('adds and removes random number of documents properly', async t => {
|
||||
// random number of providers
|
||||
const server = await newHocuspocus(t)
|
||||
const numberOfProviders = randomInteger(10, 100)
|
||||
const providers = []
|
||||
for (let index = 0; index < numberOfProviders; index += 1) {
|
||||
providers.push(
|
||||
newHocuspocusProvider(t, server, { name: `foobar-${index}` }),
|
||||
)
|
||||
}
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getDocumentsCount(), numberOfProviders)
|
||||
})
|
||||
|
||||
// random number of disconnects
|
||||
const numberOfDisconnects = randomInteger(1, numberOfProviders)
|
||||
for (let index = 0; index < numberOfDisconnects; index += 1) {
|
||||
providers[index].disconnect()
|
||||
providers[index].configuration.websocketProvider.disconnect()
|
||||
}
|
||||
|
||||
// check the count
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(server.getConnectionsCount(), numberOfProviders - numberOfDisconnects)
|
||||
})
|
||||
})
|
||||
105
tests/server/listen.ts
Normal file
105
tests/server/listen.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import test from 'ava'
|
||||
import { Server } from '@hocuspocus/server'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
test('should respond with OK', async t => {
|
||||
const hocuspocus = await newHocuspocus(t)
|
||||
|
||||
const response = await fetch(hocuspocus.server!.httpURL)
|
||||
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('should respond with status 200', async t => {
|
||||
const hocuspocus = await newHocuspocus(t)
|
||||
|
||||
const response = await fetch(hocuspocus.server!.httpURL)
|
||||
|
||||
t.is(await response.status, 200)
|
||||
})
|
||||
|
||||
test('should respond with OK on a custom port', async t => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
port: 4000,
|
||||
})
|
||||
|
||||
const response = await fetch(hocuspocus.server!.httpURL)
|
||||
|
||||
t.is(hocuspocus.server!.address.port, 4000)
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('should respond with OK on a custom port passed to listen()', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
server.listen(4001)
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
|
||||
t.is(server.address.port, 4001)
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('should take a custom port and a callback', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
await new Promise(async resolve => {
|
||||
server.listen(4002, () => {
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
|
||||
t.is(server.address.port, 4002)
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('should execute a callback', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
await new Promise(async resolve => {
|
||||
server.listen(0, async () => {
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
|
||||
test('should have the custom port as a parameter in the callback', async t => {
|
||||
const server = new Server({
|
||||
port: 0,
|
||||
stopOnSignals: false,
|
||||
})
|
||||
|
||||
t.teardown(() => server.httpServer.close())
|
||||
|
||||
await new Promise(async resolve => {
|
||||
server.listen(0, async ({ port }: any) => {
|
||||
t.is(port, server.address.port)
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
const response = await fetch(server.httpURL)
|
||||
|
||||
t.is(await response.text(), 'Welcome to Hocuspocus!')
|
||||
})
|
||||
333
tests/server/onAuthenticate.ts
Normal file
333
tests/server/onAuthenticate.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import type {
|
||||
onAuthenticatePayload,
|
||||
onLoadDocumentPayload,
|
||||
} from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
newHocuspocusProviderWebsocket,
|
||||
sleep,
|
||||
} from "../utils/index.ts";
|
||||
import { retryableAssertion } from "../utils/retryableAssertion.ts";
|
||||
|
||||
test("executes the onAuthenticate callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("executes the onAuthenticate callback from a custom extension", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class CustomExtension {
|
||||
async onAuthenticate() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("confirms the `Token` message with an `Authenticated` message", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
// success
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
onAuthenticated() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("replies with a `PermissionDenied` message when authentication fails", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
// fail
|
||||
throw Error();
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("passes context from onAuthenticate to onLoadDocument", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
};
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return mockContext;
|
||||
},
|
||||
async onLoadDocument({ context }: onLoadDocumentPayload) {
|
||||
t.deepEqual(context, mockContext);
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores the authentication token when having no onAuthenticate hook", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
onOpen() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("has the authentication token", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token }: onAuthenticatePayload) {
|
||||
t.is(token, "SUPER-SECRET-TOKEN");
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("does not disconnect provider when the onAuthenticate hook throws an Error", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
throw new Error();
|
||||
},
|
||||
// MUST NOT BE CALLED
|
||||
async onLoadDocument() {
|
||||
t.fail(
|
||||
"WARNING: When onAuthenticate fails onLoadDocument must not be called.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onClose() {
|
||||
t.fail();
|
||||
},
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(
|
||||
provider.configuration.websocketProvider.status,
|
||||
WebSocketStatus.Connected,
|
||||
);
|
||||
tt.is(server.getDocumentsCount(), 0);
|
||||
tt.is(server.getConnectionsCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("connects with the correct token", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token }: onAuthenticatePayload) {
|
||||
if (token !== "SUPER-SECRET-TOKEN") {
|
||||
throw new Error();
|
||||
}
|
||||
},
|
||||
async onLoadDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "SUPER-SECRET-TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("onAuthenticate has access to document name", async (t) => {
|
||||
const docName = "superSecretDoc";
|
||||
const requiredToken = "SUPER-SECRET-TOKEN";
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token, documentName }: onAuthenticatePayload) {
|
||||
if (documentName !== docName) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if (token !== requiredToken) {
|
||||
throw new Error();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: requiredToken,
|
||||
name: docName,
|
||||
onAuthenticated() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("onAuthenticate wrong auth only disconnects affected doc (when multiplexing)", async (t) => {
|
||||
const docName = "superSecretDoc";
|
||||
const requiredToken = "SUPER-SECRET-TOKEN";
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token, documentName }: onAuthenticatePayload) {
|
||||
if (documentName !== docName) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if (token !== requiredToken) {
|
||||
throw new Error();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const providerFail = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "wrongToken",
|
||||
name: "otherDocu",
|
||||
onAuthenticated() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(100);
|
||||
|
||||
const providerOK = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: requiredToken,
|
||||
name: docName,
|
||||
onAuthenticationFailed() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(socket.status, WebSocketStatus.Connected);
|
||||
tt.is(server.getDocumentsCount(), 1);
|
||||
tt.is(server.getConnectionsCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("onAuthenticate readonly auth only affects 1 doc (when multiplexing)", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({
|
||||
token,
|
||||
documentName,
|
||||
connectionConfig,
|
||||
}: onAuthenticatePayload) {
|
||||
if (token === "readonly") {
|
||||
connectionConfig.readOnly = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const providerReadOnly = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "readonly",
|
||||
name: "doc1",
|
||||
onAuthenticationFailed() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
const providerOK = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "read+write",
|
||||
name: "doc2",
|
||||
onAuthenticationFailed() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(socket.status, WebSocketStatus.Connected);
|
||||
tt.is(socket.status, WebSocketStatus.Connected);
|
||||
tt.is(server.getDocumentsCount(), 2);
|
||||
tt.is(server.getConnectionsCount(), 1);
|
||||
tt.is(socket.status, WebSocketStatus.Connected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
t.is(
|
||||
server.documents.get("doc1")!.connections.keys().next().value!
|
||||
.readOnly,
|
||||
true,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
t.is(
|
||||
server.documents.get("doc2")!.connections.keys().next().value!
|
||||
.readOnly,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("onAuthenticate is called even if no token is provided", async (t) => {
|
||||
const docName = "superSecretDoc";
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ documentName }: onAuthenticatePayload) {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: docName,
|
||||
});
|
||||
});
|
||||
});
|
||||
85
tests/server/onAwarenessUpdate.ts
Normal file
85
tests/server/onAwarenessUpdate.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'ava'
|
||||
import type { onAwarenessUpdatePayload } from '@hocuspocus/server'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('executes the onAwarenessUpdate callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAwarenessUpdate({ states }) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
t.is(states.length, 1)
|
||||
t.is(states[0].foo, 'bar')
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the onAwarenessUpdate callback from a custom extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
class CustomExtension {
|
||||
async onAwarenessUpdate({ states }: onAwarenessUpdatePayload) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
t.is(states.length, 1)
|
||||
t.is(states[0].foo, 'bar')
|
||||
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('forwards the originating connection on onAwarenessUpdate', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return { user: { id: 'u-1', displayName: 'Test User' } }
|
||||
},
|
||||
async onAwarenessUpdate({ connection, states }) {
|
||||
if (resolved || states.length === 0) return
|
||||
resolved = true
|
||||
|
||||
t.truthy(connection, 'connection should be defined for client-originated awareness updates')
|
||||
t.is((connection?.context as { user: { id: string } } | undefined)?.user?.id, 'u-1')
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'anything',
|
||||
onConnect() {
|
||||
provider.setAwarenessField('foo', 'bar')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
158
tests/server/onChange.ts
Normal file
158
tests/server/onChange.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import test from 'ava'
|
||||
import type { onChangePayload } from '@hocuspocus/server'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('onChange callback receives updates', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
return mockContext
|
||||
},
|
||||
async onChange({ document, context }) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
const value = document.getArray('foo').get(0)
|
||||
t.is(value, 'bar')
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes onChange callback from an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
class CustomExtension {
|
||||
async onChange({ document }: onChangePayload) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
const value = document.getArray('foo').get(0)
|
||||
|
||||
t.is(value, 'bar')
|
||||
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('onChange callback is not called after onLoadDocument', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onChange(data) {
|
||||
t.fail()
|
||||
},
|
||||
async onLoadDocument({ document }) {
|
||||
document.getArray('foo').insert(0, ['bar'])
|
||||
|
||||
return document
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('has the server instance', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onChange({ instance }) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
t.is(instance, server)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('onChange callback isn\'t called for every new client', async t => {
|
||||
let onConnectCount = 0
|
||||
let onChangeCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
onConnectCount += 1
|
||||
},
|
||||
async onChange() {
|
||||
onChangeCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
newHocuspocusProvider(t, server)
|
||||
|
||||
resolve('done')
|
||||
})
|
||||
|
||||
await retryableAssertion(t, tt => {
|
||||
tt.is(onConnectCount, 2)
|
||||
tt.is(onChangeCount, 0)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test('onChange works properly for changes from direct connections', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
name: 'hocuspocus-test',
|
||||
async onChange(data) {
|
||||
resolve('')
|
||||
t.pass()
|
||||
},
|
||||
})
|
||||
|
||||
const conn = await server.openDirectConnection('hocuspocus-test')
|
||||
t.is(server.getConnectionsCount(), 1)
|
||||
|
||||
conn.transact(doc => {
|
||||
doc.getMap('t').set('g', 'b')
|
||||
})
|
||||
})
|
||||
})
|
||||
70
tests/server/onClose.ts
Normal file
70
tests/server/onClose.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'ava'
|
||||
import { WebSocketStatus } from '@hocuspocus/provider'
|
||||
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
|
||||
import { retryableAssertion } from '../utils/retryableAssertion.ts'
|
||||
|
||||
test('server closes connection when receiving close event from provider', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {})
|
||||
const socket = newHocuspocusProviderWebsocket(t, server, {})
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
name: 'hocuspocus-test',
|
||||
})
|
||||
|
||||
await retryableAssertion(t, t2 => {
|
||||
t2.is(server.getConnectionsCount(), 1)
|
||||
})
|
||||
|
||||
await retryableAssertion(t, t2 => {
|
||||
provider1.destroy()
|
||||
t2.is(server.getConnectionsCount(), 0)
|
||||
})
|
||||
|
||||
resolve('ok')
|
||||
})
|
||||
})
|
||||
|
||||
test('server doesnt close connection after receiving close event from all connections', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {})
|
||||
const socket = newHocuspocusProviderWebsocket(t, server, {})
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
name: 'hocuspocus-test',
|
||||
})
|
||||
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
name: 'hocuspocus-test2',
|
||||
})
|
||||
|
||||
await retryableAssertion(t, t2 => {
|
||||
t2.is(server.getConnectionsCount(), 1)
|
||||
})
|
||||
|
||||
socket.shouldConnect = false
|
||||
provider1.destroy()
|
||||
|
||||
t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
|
||||
setTimeout(async () => {
|
||||
t.is(server.getConnectionsCount(), 1)
|
||||
provider2.destroy()
|
||||
|
||||
t.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
t.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
|
||||
await retryableAssertion(t, t2 => {
|
||||
t2.is(server.getConnectionsCount(), 1)
|
||||
t2.is(provider1.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
t2.is(provider2.configuration.websocketProvider.status, WebSocketStatus.Connected)
|
||||
})
|
||||
|
||||
resolve('ok')
|
||||
}, 200)
|
||||
})
|
||||
})
|
||||
62
tests/server/onConfigure.ts
Normal file
62
tests/server/onConfigure.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from 'ava'
|
||||
import type { Hocuspocus } from '@hocuspocus/server'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('onConfigure callback is executed', async t => {
|
||||
await new Promise(async resolve => {
|
||||
let givenInstance = null
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConfigure({ instance }) {
|
||||
givenInstance = instance
|
||||
},
|
||||
})
|
||||
|
||||
t.is(givenInstance as unknown as Hocuspocus, server)
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
test('executes onConfigure callback from an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onConfigure() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('has the configuration', async t => {
|
||||
await new Promise(async resolve => {
|
||||
newHocuspocus(t, {
|
||||
debounce: 2001,
|
||||
async onConfigure({ configuration }) {
|
||||
t.is(configuration.debounce, 2001)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('has the version', async t => {
|
||||
await new Promise(async resolve => {
|
||||
newHocuspocus(t, {
|
||||
async onConfigure({ version }) {
|
||||
t.truthy(version)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
330
tests/server/onConnect.ts
Normal file
330
tests/server/onConnect.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import test from "ava";
|
||||
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
|
||||
|
||||
test("executes the onConnect callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses connection when an error is thrown", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect() {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("executes the onConnect callback from an extension", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
class CustomExtension {
|
||||
async onConnect() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
server.configure({
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has the document name", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ documentName }) {
|
||||
t.is(documentName, "hocuspocus-test");
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("sets the provider to readOnly", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ connectionConfig }) {
|
||||
connectionConfig.readOnly = true;
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
for (const connection of server.documents.get("hocuspocus-test")?.connections.keys() ?? []) {
|
||||
t.is(connection.readOnly, true);
|
||||
}
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const weirdDocumentNames = [
|
||||
"not-weird",
|
||||
"äöü",
|
||||
"<>{}|^ß",
|
||||
"with space",
|
||||
"with/slash",
|
||||
"with\backslash",
|
||||
"a-very-long-document-name-which-should-not-make-any-problems-at-all",
|
||||
"🌟",
|
||||
":",
|
||||
"—",
|
||||
"漢",
|
||||
"triple space",
|
||||
"*",
|
||||
];
|
||||
|
||||
weirdDocumentNames.forEach((weirdDocumentName) => {
|
||||
test(`encodes weird document names: "${weirdDocumentName}"`, async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ documentName }) {
|
||||
t.is(documentName, weirdDocumentName);
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: weirdDocumentName,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty document name via WebSocket", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: "",
|
||||
onAuthenticationFailed() {
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects whitespace-only document name via WebSocket", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: " ",
|
||||
onAuthenticationFailed() {
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when the onConnect hook throws an Error", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onConnect() {
|
||||
throw new Error();
|
||||
},
|
||||
// MUST NOT BE CALLED
|
||||
async onLoadDocument() {
|
||||
t.fail(
|
||||
"WARNING: When onConnect fails onLoadDocument must not be called.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when the onConnect hook returns a rejecting promise", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onConnect() {
|
||||
return Promise.reject();
|
||||
},
|
||||
// MUST NOT BE CALLED
|
||||
async onLoadDocument() {
|
||||
t.fail(
|
||||
"WARNING: When onConnect fails onLoadDocument must not be called.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("has the request headers", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ requestHeaders }) {
|
||||
t.is(requestHeaders.get("connection") !== null, true);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has the whole request", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ request }) {
|
||||
t.is(new URL(request.url).pathname, "/");
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has the socketId", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ socketId }) {
|
||||
t.is(socketId !== undefined, true);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has the server instance", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ instance }) {
|
||||
t.is(instance, server);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults to readOnly = false", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ connectionConfig }) {
|
||||
t.is(connectionConfig.readOnly, false);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("cleans up correctly when provider disconnects during onLoadDocument", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let provider: HocuspocusProvider;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
onLoadDocument: async () => {
|
||||
provider.configuration.websocketProvider.disconnect();
|
||||
provider.disconnect();
|
||||
|
||||
// pretent we loaded data from async source
|
||||
await sleep(100);
|
||||
},
|
||||
});
|
||||
|
||||
provider = newHocuspocusProvider(t, server, {
|
||||
name: "super-unique-name",
|
||||
async onDisconnect() {
|
||||
await sleep(100);
|
||||
|
||||
t.is(
|
||||
server.documents.get("super-unique-name"),
|
||||
undefined,
|
||||
"no documents",
|
||||
);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the connections count is correct", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async connected() {
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has connection.readOnly", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ connectionConfig }) {
|
||||
t.is(connectionConfig.readOnly, false);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("has the request", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ request }) {
|
||||
t.truthy(request.url);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
275
tests/server/onDestroy.ts
Normal file
275
tests/server/onDestroy.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import test from "ava";
|
||||
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
|
||||
import { retryableAssertion } from "../utils/retryableAssertion.ts";
|
||||
|
||||
test("executes the onDestroy hook and has the instance", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onDestroy({ instance }) {
|
||||
t.is(instance, hocuspocus);
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
await hocuspocus.server!.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy works if no document is open", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const hocuspocus = await newHocuspocus(t);
|
||||
|
||||
await hocuspocus.server!.destroy();
|
||||
|
||||
t.pass();
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("executes the onDestroy hook from a custom extension", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class CustomExtension {
|
||||
async onDestroy() {
|
||||
t.pass();
|
||||
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
await hocuspocus.server!.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy closes all connections", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const hocuspocus = await newHocuspocus(t);
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, hocuspocus);
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
|
||||
|
||||
t.is(hocuspocus.getConnectionsCount(), 1);
|
||||
t.is(hocuspocus.getDocumentsCount(), 1);
|
||||
|
||||
await hocuspocus.server!.destroy();
|
||||
|
||||
t.is(hocuspocus.getConnectionsCount(), 0);
|
||||
t.is(hocuspocus.getDocumentsCount(), 0);
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy does not call onStoreDocument if nothing debounced", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
|
||||
|
||||
await server.server!.destroy();
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy does not call onStoreDocument after debounced onStoreDocument executes", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let called = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 200,
|
||||
unloadImmediately: true,
|
||||
async onStoreDocument() {
|
||||
called += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
|
||||
|
||||
// Wait for the debounced onStoreDocument to execute
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
await server.server!.destroy();
|
||||
|
||||
t.is(called, 1);
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy calls onStoreDocument before returning if debounced", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let called = false;
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, hocuspocus, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, hocuspocus);
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
|
||||
|
||||
t.is(called, false);
|
||||
await hocuspocus.server!.destroy();
|
||||
t.is(called, true);
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy calls onStoreDocument before returning, even with unloadImmediately=false if debounced", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let called = false;
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
called = true;
|
||||
},
|
||||
unloadImmediately: false,
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, hocuspocus, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, hocuspocus);
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
|
||||
await retryableAssertion(t, (t2) => t2.is(provider.synced, true));
|
||||
|
||||
t.is(called, false);
|
||||
await hocuspocus.server!.destroy();
|
||||
t.is(called, true);
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy calls onStoreDocument before returning, even with unloadImmediately=false, with multiple docs if debounced", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let called = 0;
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
called += 1;
|
||||
},
|
||||
unloadImmediately: false,
|
||||
});
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test1",
|
||||
onSynced() {
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
const provider2 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test2",
|
||||
onSynced() {
|
||||
provider2.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
const provider3 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test3",
|
||||
onSynced() {
|
||||
provider3.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
|
||||
await retryableAssertion(t, (t2) => t2.is(provider2.synced, true));
|
||||
await retryableAssertion(t, (t2) => t2.is(provider3.synced, true));
|
||||
|
||||
// Wait for all changes to reach the server and trigger debounced stores
|
||||
await retryableAssertion(t, (t2) => {
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test1"));
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test2"));
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test3"));
|
||||
});
|
||||
|
||||
t.is(called, 0);
|
||||
await hocuspocus.server!.destroy();
|
||||
await retryableAssertion(t, (t2) => t2.is(called, 3));
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy calls onStoreDocument before returning, with multiple docs if debounced", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let called = 0;
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
called += 1;
|
||||
},
|
||||
unloadImmediately: true,
|
||||
});
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test1",
|
||||
onSynced() {
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
const provider2 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test2",
|
||||
onSynced() {
|
||||
provider2.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
const provider3 = newHocuspocusProvider(t, hocuspocus, {
|
||||
name: "test3",
|
||||
onSynced() {
|
||||
provider3.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(provider1.synced, true));
|
||||
await retryableAssertion(t, (t2) => t2.is(provider2.synced, true));
|
||||
await retryableAssertion(t, (t2) => t2.is(provider3.synced, true));
|
||||
|
||||
// Wait for all changes to reach the server and trigger debounced stores
|
||||
await retryableAssertion(t, (t2) => {
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test1"));
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test2"));
|
||||
t2.true(hocuspocus.debouncer.isDebounced("onStoreDocument-test3"));
|
||||
});
|
||||
|
||||
t.is(called, 0);
|
||||
await hocuspocus.server!.destroy();
|
||||
|
||||
await retryableAssertion(t, (t2) => t2.is(called, 3));
|
||||
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
114
tests/server/onDisconnect.ts
Normal file
114
tests/server/onDisconnect.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('executes the onDisconnect callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onDisconnect() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the onDisconnect callback from an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
class CustomExtension {
|
||||
async onDisconnect() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
server.configure({
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
|
||||
onConnect() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('passes the context to the onLoadDocument callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
}
|
||||
|
||||
server.configure({
|
||||
async onConnect() {
|
||||
return mockContext
|
||||
},
|
||||
async onDisconnect({ context }) {
|
||||
t.deepEqual(context, mockContext)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
|
||||
onConnect() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('has the server instance', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onDisconnect({ instance }) {
|
||||
t.is(instance, server)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
|
||||
onConnect() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('the connections count is correct', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onDisconnect() {
|
||||
t.is(server.getConnectionsCount(), 0)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onConnect() {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
52
tests/server/onListen.ts
Normal file
52
tests/server/onListen.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
test('executes the onListen callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
newHocuspocus(t, {
|
||||
async onListen() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the onListen callback from an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onListen() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('has the configuration', async t => {
|
||||
await new Promise(async resolve => {
|
||||
newHocuspocus(t, {
|
||||
async onListen({ configuration }) {
|
||||
t.is(configuration.quiet, true)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('has the port', async t => {
|
||||
await new Promise(async resolve => {
|
||||
newHocuspocus(t, {
|
||||
async onListen({ port }) {
|
||||
t.truthy(port)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
518
tests/server/onLoadDocument.ts
Normal file
518
tests/server/onLoadDocument.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import test from "ava";
|
||||
import * as Y from "yjs";
|
||||
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test("executes the onLoadDocument callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("executes the onLoadDocument callback from an extension", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class CustomExtension {
|
||||
async onLoadDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("passes the context and connection to the onLoadDocument callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const mockContext = {
|
||||
user: 123,
|
||||
};
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ connectionConfig }) {
|
||||
connectionConfig.readOnly = true;
|
||||
return mockContext;
|
||||
},
|
||||
async onLoadDocument({ context, connectionConfig }) {
|
||||
t.deepEqual(context, mockContext);
|
||||
t.deepEqual(connectionConfig, {
|
||||
readOnly: true,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
test("sets the provider to readOnly", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument({ connectionConfig }) {
|
||||
connectionConfig.readOnly = true;
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
for (const connection of server.documents.get("hocuspocus-test")?.connections.keys() ?? []) {
|
||||
t.is(connection.readOnly, true);
|
||||
}
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a new document in the onLoadDocument callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onLoadDocument({ document }) {
|
||||
// delay more accurately simulates a database fetch
|
||||
return new Promise(async (resolve) => {
|
||||
setTimeout(() => {
|
||||
document.getArray("foo").insert(0, ["bar"]);
|
||||
resolve(document);
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
const value = provider.document.getArray("foo").get(0);
|
||||
t.is(value, "bar");
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("does not apply the document to itself when onLoadDocument returns it", async (t) => {
|
||||
const origins: unknown[] = [];
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument({ document }) {
|
||||
document.on("afterTransaction", (transaction) => {
|
||||
origins.push(transaction.origin);
|
||||
});
|
||||
|
||||
// the content this hook "loaded", written with an explicit origin so
|
||||
// it doubles as a control: without it, a listener that never fires
|
||||
// would satisfy the assertion below vacuously
|
||||
document.transact(() => {
|
||||
document.getArray("foo").insert(0, ["bar"]);
|
||||
}, "loaded");
|
||||
|
||||
return document;
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
t.true(origins.includes("loaded"), "the afterTransaction listener never fired");
|
||||
|
||||
// the self-apply calls applyUpdate() without an origin, and the client sync
|
||||
// carries a ConnectionTransactionOrigin, so a null origin can only be the
|
||||
// document having been applied to itself
|
||||
const selfApplies = origins.filter((origin) => origin == null);
|
||||
|
||||
t.is(
|
||||
selfApplies.length,
|
||||
0,
|
||||
`expected no null-origin transaction after onLoadDocument returned the same document, got ${selfApplies.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("applies a document returned by the onLoadDocument callback", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument() {
|
||||
const loaded = new Y.Doc();
|
||||
loaded.getArray("foo").insert(0, ["bar"]);
|
||||
|
||||
return loaded;
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
t.is(provider.document.getArray("foo").get(0), "bar");
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple simultaneous connections do not create multiple documents", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onLoadDocument({ document }) {
|
||||
// delay more accurately simulates a database fetch
|
||||
return new Promise(async (resolve) => {
|
||||
setTimeout(() => {
|
||||
document.getArray("foo").insert(0, ["bar"]);
|
||||
resolve(document);
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
t.is(server.documents.size, 1);
|
||||
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple simultaneous connections wait for the document to be loaded", async (t) => {
|
||||
t.plan(6);
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
let resolveOnLoadDocument: () => void = () => {};
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
onLoadDocument({ document }) {
|
||||
// delay more accurately simulates a database fetch
|
||||
return new Promise(async (innerResolve) => {
|
||||
resolveOnLoadDocument = () => {
|
||||
document.getArray("foo").insert(0, ["bar"]);
|
||||
innerResolve(document);
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, server);
|
||||
const provider2 = newHocuspocusProvider(t, server);
|
||||
let provider1Synced = false;
|
||||
let provider2Synced = false;
|
||||
|
||||
provider1.on("synced", () => {
|
||||
provider1Synced = true;
|
||||
const value = provider1.document.getArray("foo").get(0);
|
||||
t.is(value, "bar");
|
||||
});
|
||||
provider2.on("synced", () => {
|
||||
provider2Synced = true;
|
||||
const value = provider2.document.getArray("foo").get(0);
|
||||
t.is(value, "bar");
|
||||
});
|
||||
|
||||
await sleep(200);
|
||||
|
||||
t.false(provider1Synced, "provider1Synced");
|
||||
t.false(provider2Synced, "provider2Synced");
|
||||
|
||||
resolveOnLoadDocument();
|
||||
|
||||
await sleep(200);
|
||||
|
||||
t.true(provider1Synced, "provider1Synced");
|
||||
t.true(provider2Synced, "provider2Synced");
|
||||
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
|
||||
test("has the server instance", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument({ instance }) {
|
||||
t.is(instance, server);
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When onLoadDocument fails (for whatever reason), the connection attempt will fail.
|
||||
*/
|
||||
test("stops when an error is thrown in onLoadDocument", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument() {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when an error is thrown in onLoadDocument, even when authenticated", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
async onLoadDocument() {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "super-secret-token",
|
||||
onAuthenticationFailed() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
onClose() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("disconnects all clients related to the document when an error is thrown in onLoadDocument", async (t) => {
|
||||
const resolvesNeeded = 2;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument() {
|
||||
return new Promise((resolve, fail) => {
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
fail("ERROR");
|
||||
}, 250);
|
||||
});
|
||||
},
|
||||
async onStoreDocument(data) {
|
||||
t.fail("MUST NOT call onStoreDocument");
|
||||
},
|
||||
});
|
||||
|
||||
let resolvedNumber = 0;
|
||||
const resolver = () => {
|
||||
resolvedNumber += 1;
|
||||
|
||||
if (resolvedNumber >= resolvesNeeded) {
|
||||
t.is(server.documents.size, 0);
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
resolve("done");
|
||||
}
|
||||
};
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed(event) {
|
||||
provider1.disconnect();
|
||||
resolver();
|
||||
},
|
||||
});
|
||||
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
provider2.disconnect();
|
||||
resolver();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("destroys the document when onLoadDocument throws", async (t) => {
|
||||
let loadedDocument: Y.Doc | undefined;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument({ document }) {
|
||||
loadedDocument = document;
|
||||
|
||||
throw new Error("Something went wrong");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed() {
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
t.truthy(loadedDocument, "onLoadDocument never ran");
|
||||
|
||||
// Awareness starts a setInterval in its constructor that is only cleared by
|
||||
// the document's destroy(), so a document that is never destroyed leaves a
|
||||
// live timer behind for every failed load
|
||||
t.true(
|
||||
loadedDocument?.isDestroyed,
|
||||
"the document created for the failed load was never destroyed",
|
||||
);
|
||||
});
|
||||
|
||||
test("if a new connection connects while the previous connection still fetches the document, it will just work properly", async (t) => {
|
||||
t.plan(11);
|
||||
|
||||
let callsToOnLoadDocument = 0;
|
||||
const resolvesNeeded = 10;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
let resolvedNumber = 0;
|
||||
const resolver = () => {
|
||||
resolvedNumber += 1;
|
||||
|
||||
if (resolvedNumber >= resolvesNeeded) {
|
||||
t.is(callsToOnLoadDocument, 1);
|
||||
resolve("done");
|
||||
}
|
||||
};
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
// This test asserts on the document state at each individual inbound
|
||||
// message, so it pins the unbatched fan-out. With the default
|
||||
// `flushDelay: 0` a broadcast lands one event loop turn later, which
|
||||
// reorders it relative to the sync acks and shifts these indices.
|
||||
flushDelay: false,
|
||||
onLoadDocument({ document }) {
|
||||
return new Promise(async (resolve) => {
|
||||
setTimeout(() => {
|
||||
callsToOnLoadDocument += 1;
|
||||
document
|
||||
.getArray("foo")
|
||||
.insert(0, [`bar-${callsToOnLoadDocument}`]);
|
||||
resolve(document);
|
||||
}, 5000);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let provider1MessagesReceived = 0;
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced({ state }) {
|
||||
// if (!state) return
|
||||
t.is(server.documents.size, 1);
|
||||
|
||||
const value = provider.document.getArray("foo").get(0);
|
||||
t.is(value, "bar-1");
|
||||
|
||||
setTimeout(() => {
|
||||
provider.document
|
||||
.getArray("foo")
|
||||
.insert(0, ["bar-updatedAfterProvider1Synced"]);
|
||||
}, 100);
|
||||
|
||||
resolver();
|
||||
},
|
||||
onMessage() {
|
||||
if (!provider.isSynced) return;
|
||||
provider1MessagesReceived += 1;
|
||||
|
||||
const value = provider.document.getArray("foo").get(0);
|
||||
|
||||
if (provider1MessagesReceived === 1) {
|
||||
// do nothing, this is just the ACK for the sync
|
||||
} else if (provider1MessagesReceived === 2) {
|
||||
// do nothing, this is just the ACK for the received update (set "bar-updatedAfterProvider1Synced")
|
||||
} else if (provider1MessagesReceived === 3) {
|
||||
t.is(value, "bar-updatedAfterProvider1Synced");
|
||||
} else {
|
||||
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
|
||||
}
|
||||
|
||||
resolver();
|
||||
},
|
||||
});
|
||||
|
||||
let provider2MessagesReceived = 0;
|
||||
setTimeout(() => {
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
onSynced({ state }) {
|
||||
// if (!state) return
|
||||
|
||||
t.is(server.documents.size, 1);
|
||||
|
||||
const value = provider2.document.getArray("foo").get(0);
|
||||
t.is(value, "bar-1");
|
||||
|
||||
resolver();
|
||||
},
|
||||
onMessage(data) {
|
||||
if (!provider2.isSynced) return;
|
||||
provider2MessagesReceived += 1;
|
||||
|
||||
setTimeout(() => {
|
||||
const value = provider2.document.getArray("foo").get(0);
|
||||
|
||||
if (provider2MessagesReceived === 1) {
|
||||
// initial state is now synced
|
||||
t.is(value, "bar-1");
|
||||
} else if (provider2MessagesReceived === 2) {
|
||||
t.is(value, "bar-updatedAfterProvider1Synced");
|
||||
setTimeout(() => {
|
||||
provider.document
|
||||
.getArray("foo")
|
||||
.insert(0, ["bar-updatedAfterProvider2ReceivedMessageFrom1"]);
|
||||
}, 100);
|
||||
} else {
|
||||
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
|
||||
}
|
||||
resolver();
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when an error is thrown in onLoadDocument and passed on to the provider", async (t) => {
|
||||
class CustomError extends Error {
|
||||
code = 1234;
|
||||
reason = "mycustomreason";
|
||||
|
||||
constructor(documentName: string) {
|
||||
super(`Failed loading ${documentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onLoadDocument() {
|
||||
throw new CustomError("testdoc");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
onAuthenticationFailed(e) {
|
||||
t.is(e.reason, "mycustomreason");
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
81
tests/server/onRequest.ts
Normal file
81
tests/server/onRequest.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from 'ava'
|
||||
import type { onRequestPayload } from '@hocuspocus/server'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
test('executes the onRequest callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onRequest({ request }: onRequestPayload) {
|
||||
t.is(request.url, '/foobar')
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
fetch(`${hocuspocus.server!.httpURL}/foobar`).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the onRequest callback of a custom extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onRequest({ response }: onRequestPayload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||
response.end('I like cats.')
|
||||
|
||||
return reject()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await fetch(hocuspocus.server!.httpURL)
|
||||
t.is(await response.text(), 'I like cats.')
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
test('can intercept specific URLs', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onRequest({ response, request }: onRequestPayload) {
|
||||
if (request.url === '/foobar') {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||
response.end('I like cats.')
|
||||
|
||||
return reject()
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const interceptedResponse = await fetch(`${hocuspocus.server!.httpURL}/foobar`)
|
||||
t.is(await interceptedResponse.text(), 'I like cats.')
|
||||
|
||||
const regularResponse = await fetch(hocuspocus.server!.httpURL)
|
||||
t.is(await regularResponse.text(), 'Welcome to Hocuspocus!')
|
||||
resolve('done')
|
||||
})
|
||||
})
|
||||
|
||||
test('has the instance', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
async onRequest({ instance }) {
|
||||
t.is(instance, hocuspocus)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
fetch(`${hocuspocus.server!.httpURL}/foobar`).catch(() => {})
|
||||
})
|
||||
})
|
||||
101
tests/server/onStateless.ts
Normal file
101
tests/server/onStateless.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import test from 'ava'
|
||||
import type { onStatelessPayload } from '@hocuspocus/server'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('broadcast stateless message to all connections', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const payloadToSend = 'STATELESS-MESSAGE'
|
||||
const server = await newHocuspocus(t, {
|
||||
onStateless: async ({ document }) => {
|
||||
await document.broadcastStateless(payloadToSend)
|
||||
},
|
||||
})
|
||||
|
||||
let count = 2
|
||||
const onStatelessCallback = (payload: string) => {
|
||||
t.is(payload, payloadToSend)
|
||||
count -= 1
|
||||
if (count === 0) {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
newHocuspocusProvider(t, server, { onStateless: ({ payload }) => onStatelessCallback(payload) })
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onStateless: ({ payload }) => onStatelessCallback(payload),
|
||||
onSynced: () => {
|
||||
provider.sendStateless(payloadToSend)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('send a stateless message to a specific connection', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onStateless: async ({ connection }: onStatelessPayload) => {
|
||||
connection.sendStateless('This is a specific message.')
|
||||
},
|
||||
})
|
||||
|
||||
await newHocuspocusProvider(t, server, {
|
||||
onStateless: async () => {
|
||||
throw Error('The provider1 should not receive messages')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = await newHocuspocusProvider(t, server, {
|
||||
onSynced: () => {
|
||||
provider.sendStateless('Send stateless message, and then a stateless message is will be received')
|
||||
},
|
||||
onStateless: () => {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('calls the onStateless hook', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const payloadToSend = 'STATELESS-MESSAGE'
|
||||
class CustomExtension {
|
||||
async onStateless({ payload }: onStatelessPayload) {
|
||||
t.is(payload, payloadToSend)
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = await newHocuspocusProvider(t, server, {
|
||||
onSynced: async () => {
|
||||
provider.sendStateless(payloadToSend)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('the server actively sends a stateless message', async t => {
|
||||
const payloadToSend = 'STATELESS-MESSAGE'
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
await new Promise(resolve => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced: async () => {
|
||||
server.documents.get('hocuspocus-test')?.broadcastStateless(payloadToSend)
|
||||
},
|
||||
onStateless: async ({ payload }) => {
|
||||
t.is(payload, payloadToSend)
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
768
tests/server/onStoreDocument.ts
Normal file
768
tests/server/onStoreDocument.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
import type { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
newHocuspocusProviderWebsocket,
|
||||
sleep,
|
||||
} from "../utils/index.ts";
|
||||
import * as Y from "yjs";
|
||||
|
||||
test("calls the debouned onStoreDocument hook before the document is removed from memory", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 3000,
|
||||
async onStoreDocument() {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("doesn’t remove the document from memory when there’s a new connection established during onStoreDocument is called", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
return sleep(1000);
|
||||
},
|
||||
});
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
const anotherSocket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
setTimeout(() => {
|
||||
anotherSocket.connect();
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
resolve("done");
|
||||
}, 1100);
|
||||
},
|
||||
});
|
||||
newHocuspocusProvider(t, server, {
|
||||
websocketProvider: anotherSocket,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("removes the document from memory when there’s no connection after onStoreDocument is called", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
return sleep(1000);
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
provider.configuration.websocketProvider.destroy();
|
||||
provider.destroy();
|
||||
},
|
||||
onDestroy() {
|
||||
// Check if the document is removed from memory …
|
||||
setTimeout(() => {
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
|
||||
resolve("done");
|
||||
}, 1100);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("onStoreDocument callback receives document updates", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument({ document }: onStoreDocumentPayload) {
|
||||
const value = document.getArray("foo").get(0);
|
||||
t.is(value, "bar");
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("debounces document changes for onStoreDocument hooks", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let executedOnChange = 0;
|
||||
let executedOnStoreDocument = 0;
|
||||
|
||||
const hocuspocus = await newHocuspocus(t, {
|
||||
debounce: 300,
|
||||
async onChange() {
|
||||
executedOnChange += 1;
|
||||
},
|
||||
async onStoreDocument() {
|
||||
executedOnStoreDocument += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, hocuspocus, {
|
||||
onSynced() {
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
provider.document.getArray("foo").push(["bar"]);
|
||||
provider.document.getArray("foo").push(["barfoo"]);
|
||||
provider.document.getArray("foo").push(["foobar"]);
|
||||
provider.document.getArray("foo").push(["foofoo"]);
|
||||
|
||||
setTimeout(async () => {
|
||||
await hocuspocus.server!.destroy();
|
||||
|
||||
t.is(executedOnChange, 5);
|
||||
t.is(executedOnStoreDocument, 1);
|
||||
|
||||
resolve("done");
|
||||
}, 200);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("executes onStoreDocument callback from an extension", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class CustomExtension {
|
||||
async onStoreDocument({ document }: onStoreDocumentPayload) {
|
||||
const value = document.getArray("foo").get(0);
|
||||
t.is(value, "bar");
|
||||
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new CustomExtension()],
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when one of the onStoreDocument hooks throws an error", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class BreakingTheChain {
|
||||
async onStoreDocument() {
|
||||
setTimeout(() => {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}, 100);
|
||||
|
||||
// Stop it!
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
class NotExecuted {
|
||||
async onStoreDocument() {
|
||||
// This MUST NOT be executed.
|
||||
|
||||
resolve("done");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new BreakingTheChain(), new NotExecuted()],
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stops when one of the onStoreDocument hooks throws a non-Error value", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
class BreakingTheChain {
|
||||
async onStoreDocument() {
|
||||
setTimeout(() => {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}, 100);
|
||||
|
||||
// Throw a string instead of an Error object
|
||||
throw "storage unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
class NotExecuted {
|
||||
async onStoreDocument() {
|
||||
// This MUST NOT be executed.
|
||||
t.fail("NotExecuted should not run");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new BreakingTheChain(), new NotExecuted()],
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps document in memory when onStoreDocument fails", async (t) => {
|
||||
let resolveProviderSynced!: () => void;
|
||||
const providerSynced = new Promise<void>((resolve) => {
|
||||
resolveProviderSynced = resolve;
|
||||
});
|
||||
let resolveDocumentChanged!: () => void;
|
||||
const documentChanged = new Promise<void>((resolve) => {
|
||||
resolveDocumentChanged = resolve;
|
||||
});
|
||||
let resolveStoreStarted!: () => void;
|
||||
const storeStarted = new Promise<void>((resolve) => {
|
||||
resolveStoreStarted = resolve;
|
||||
});
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 100,
|
||||
onChange() {
|
||||
resolveDocumentChanged();
|
||||
},
|
||||
async onStoreDocument() {
|
||||
resolveStoreStarted();
|
||||
throw new Error("storage unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
resolveProviderSynced();
|
||||
},
|
||||
});
|
||||
|
||||
await providerSynced;
|
||||
provider.document.getArray("foo").push(["bar"]);
|
||||
await documentChanged;
|
||||
socket.destroy();
|
||||
await storeStarted;
|
||||
|
||||
const document = server.documents.get("hocuspocus-test");
|
||||
if (!document) {
|
||||
throw new Error("Expected the document to remain loaded while storing");
|
||||
}
|
||||
await document.saveMutex.waitForUnlock();
|
||||
|
||||
// Document must remain in memory since the store failed.
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
});
|
||||
|
||||
test("has the server instance", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument({ instance }) {
|
||||
t.is(instance, server);
|
||||
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
provider.on("synced", () => {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("runs hooks in the given order", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const triggered: string[] = [];
|
||||
|
||||
class Running {
|
||||
async onStoreDocument() {
|
||||
triggered.push("one");
|
||||
}
|
||||
}
|
||||
|
||||
class BreakTheChain {
|
||||
async onStoreDocument() {
|
||||
triggered.push("two");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
class NotRunning {
|
||||
async onStoreDocument() {
|
||||
triggered.push("three");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [new Running(), new BreakTheChain(), new NotRunning()],
|
||||
// lowest priority
|
||||
async onStoreDocument() {
|
||||
triggered.push("four");
|
||||
},
|
||||
async afterStoreDocument() {
|
||||
t.fail(); // this shouldnt run
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
provider.configuration.websocketProvider.destroy();
|
||||
provider.destroy();
|
||||
|
||||
setTimeout(() => {
|
||||
t.deepEqual(triggered, ["one", "two"]);
|
||||
|
||||
resolve(true);
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("allows to overwrite the order of extension with a priority", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const triggered: string[] = [];
|
||||
|
||||
class Running {
|
||||
async onStoreDocument() {
|
||||
triggered.push("one");
|
||||
}
|
||||
}
|
||||
|
||||
class BreakTheChain {
|
||||
async onStoreDocument() {
|
||||
triggered.push("two");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
class NotRunning {
|
||||
async onStoreDocument() {
|
||||
triggered.push("three");
|
||||
}
|
||||
}
|
||||
|
||||
class HighPriority {
|
||||
priority = 1000;
|
||||
|
||||
async onStoreDocument() {
|
||||
triggered.push("zero");
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
afterStoreDocument: async () => {
|
||||
t.fail();
|
||||
},
|
||||
extensions: [
|
||||
new Running(),
|
||||
new BreakTheChain(),
|
||||
new NotRunning(),
|
||||
new HighPriority(),
|
||||
],
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
provider.configuration.websocketProvider.destroy();
|
||||
provider.destroy();
|
||||
|
||||
setTimeout(() => {
|
||||
t.deepEqual(triggered, ["zero", "one", "two"]);
|
||||
|
||||
resolve(true);
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("if a connection connects while another disconnects onStoreDocument is still running, onLoadDocument will be called after onStoreDocument finished", async (t) => {
|
||||
await new Promise(async (testResolve) => {
|
||||
let isStoredOnDb = false;
|
||||
let loadCalls = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument({ instance }) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
isStoredOnDb = true;
|
||||
resolve(true);
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
async onLoadDocument() {
|
||||
return new Promise((resolve) => {
|
||||
if (loadCalls > 0) {
|
||||
t.true(isStoredOnDb);
|
||||
testResolve(true);
|
||||
}
|
||||
|
||||
loadCalls += 1;
|
||||
resolve(true);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
provider.on("synced", () => {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
provider.configuration.websocketProvider.disconnect();
|
||||
|
||||
setTimeout(() => {
|
||||
const provider2 = newHocuspocusProvider(t, server);
|
||||
|
||||
provider2.on("synced", () => {
|
||||
provider2.configuration.websocketProvider.disconnect();
|
||||
testResolve(true);
|
||||
t.pass();
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("waits before calling onStoreDocument after the last user disconnects when configured", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let startTime = 0;
|
||||
const server = await newHocuspocus(t, {
|
||||
unloadImmediately: false,
|
||||
debounce: 500,
|
||||
async onStoreDocument() {
|
||||
const endTime = Date.now();
|
||||
if (startTime === 0) {
|
||||
t.fail("startTime not set");
|
||||
} else if (endTime - startTime < 500) {
|
||||
t.fail("did not wait 500ms to call onStoreDocument when closing");
|
||||
} else {
|
||||
t.pass();
|
||||
}
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
startTime = Date.now();
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("calls debounced onStoreDocument immediately after the last user disconnects when configured", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let startTime = 0;
|
||||
const server = await newHocuspocus(t, {
|
||||
unloadImmediately: true,
|
||||
debounce: 500,
|
||||
async onStoreDocument() {
|
||||
const endTime = Date.now();
|
||||
if (startTime === 0) {
|
||||
t.fail("startTime not set");
|
||||
} else if (endTime - startTime < 500) {
|
||||
t.pass();
|
||||
} else {
|
||||
t.fail("did not call onStoreDocument immediately when closing");
|
||||
}
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
// Dummy change to trigger onStoreDocument
|
||||
provider.document.getArray("foo").push(["foo"]);
|
||||
startTime = Date.now();
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("does not call the onStoreDocument hook if document is not changed after the last user disconnects", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onStoreDocument() {
|
||||
t.fail(
|
||||
"no need to call onStoreDocument if the document is not changed",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onSynced() {
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
resolve("done");
|
||||
}, 200);
|
||||
});
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test("does not start a new onStoreDocument if there is already one running (should wait for the first one to finish)", async (t) => {
|
||||
/*
|
||||
If our storage backend takes more time than the debounce time to store the document,
|
||||
we might end up in a situation where multiple onStoreDocument calls are running at the same time.
|
||||
|
||||
Rough timeline:
|
||||
|
||||
1. ~0ms Client 1 connects
|
||||
2. ~10ms Client 1 makes change 1 (triggers debounced save)
|
||||
3. ~100ms Server starts saving change 1 (debounced)
|
||||
4. ~200ms Client 1 makes change 2 (triggers debounced save)
|
||||
6. ~510ms Server finishes saving change 1.
|
||||
6. ~511ms Server starts saving change 2 (debounced)
|
||||
7. ~1111ms Server finishes saving change 2.
|
||||
*/
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
let started = 0
|
||||
let finished = 0
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 100,
|
||||
async onStoreDocument() {
|
||||
if (started === 1) {
|
||||
// This is the second call
|
||||
t.is(finished, 1, "the first call must have finished before starting the second");
|
||||
resolve("done");
|
||||
} else {
|
||||
started++
|
||||
await sleep(500) // Simulate long save
|
||||
finished++
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const socket1 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket1,
|
||||
async onSynced() {
|
||||
// Change 1
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
setTimeout(() => {
|
||||
// Change 2
|
||||
provider1.document.getArray("foo").push(["bar"]);
|
||||
socket1.destroy();
|
||||
}, 200)
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
test("triggers unload only after finishing with a save in progress", async (t) => {
|
||||
/*
|
||||
Rough timeline:
|
||||
|
||||
1. ~0ms Client 1 connects
|
||||
2. ~10ms Client 1 makes change 1 (triggers debounced save)
|
||||
3. ~1000ms Server starts saving change 1 (debounced)
|
||||
4. ~1100ms Client 1 disconnects. Document is not unloaded because save is in progress.
|
||||
5. ~1500ms Server finishes saving change 1. Document is unloaded
|
||||
*/
|
||||
await new Promise(async (resolve) => {
|
||||
const start = Date.now()
|
||||
let saveStarted = false
|
||||
let saveFinished = false
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 1000,
|
||||
extensions: [{
|
||||
async onStoreDocument() {
|
||||
saveStarted = true
|
||||
await sleep(500) // Add pause to simulate long save
|
||||
saveFinished = true
|
||||
},
|
||||
async afterUnloadDocument(data) {
|
||||
t.deepEqual(saveFinished, true, "Unload should occur only after save was finished");
|
||||
t.pass()
|
||||
resolve("done");
|
||||
},
|
||||
}],
|
||||
});
|
||||
const socket1 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket1,
|
||||
async onSynced() {
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
setTimeout(() => { // Wait for sending changes
|
||||
socket1.destroy();
|
||||
}, 1100)
|
||||
},
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("does not trigger unload prematurely when a save is in progress (unloadImmediately=true)", async (t) => {
|
||||
/*
|
||||
Rough timeline:
|
||||
|
||||
1. ~0ms Client 1 connects
|
||||
2. ~10ms Client 1 makes change 1 (triggers debounced save)
|
||||
3. ~1000ms Server starts saving change 1 (debounced)
|
||||
4. ~1100ms Client 1 makes change 2 (triggers debounced save)
|
||||
5. ~1300ms Client 1 disconnects, triggering immediate save of change 2
|
||||
6. ~1500ms Server finishes saving change 1. Does not unload the document, because change 2 is still being saved.
|
||||
7. ~1600ms Client 2 connects, loads document
|
||||
9. ~1800ms Server finishes saving change 2.
|
||||
10. ~2100ms We verify that Client 2 sees both changes.
|
||||
*/
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 1000,
|
||||
extensions: [new SlowInMemoryStorage()],
|
||||
});
|
||||
|
||||
const socket1 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket1,
|
||||
|
||||
async onSynced() {
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
setTimeout(() => {
|
||||
provider1.document.getArray("foo").push(["bar"]);
|
||||
setTimeout(() => { // Wait for sending changes
|
||||
socket1.destroy();
|
||||
}, 200)
|
||||
}, 1100)
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const socket2 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket2,
|
||||
|
||||
async onSynced() {
|
||||
setTimeout(() => {
|
||||
const value = provider2.document.getArray("foo").toArray()
|
||||
t.deepEqual(value, ["foo", "bar"], "Client 2 should see both changes");
|
||||
t.pass()
|
||||
resolve("done");
|
||||
}, 500)
|
||||
},
|
||||
});
|
||||
}, 1600)
|
||||
});
|
||||
});
|
||||
|
||||
test("Does not unload prematurely when a debounced save is pending (unloadImmediately=false)", async (t) => {
|
||||
/*
|
||||
|
||||
Rough timeline:
|
||||
|
||||
1. ~0ms Client 1 connects
|
||||
2. ~10ms Client 1 makes a change 1 (triggers debounced save)
|
||||
3. ~1000ms Server starts saving change 1 (debounced)
|
||||
4. ~1100ms Client 1 makes change 2 (triggers debounced save)
|
||||
5. ~1300ms Client 1 disconnects, while change 2 is still being debounced
|
||||
6. ~1500ms Server finishes saving change 1. Does not unload the document, because change 2 is still being debounced.
|
||||
7. ~1600ms Client 2 connects
|
||||
8. ~2000ms Server starts saving change 2 (debounced)
|
||||
9. ~2500ms Server finishes saving change 2
|
||||
10. ~3000ms We verify that Client 2 sees both changes.
|
||||
|
||||
*/
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 1000,
|
||||
unloadImmediately: false,
|
||||
extensions: [new SlowInMemoryStorage()],
|
||||
});
|
||||
|
||||
const socket1 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket1,
|
||||
|
||||
async onSynced() {
|
||||
provider1.document.getArray("foo").push(["foo"]);
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
provider1.document.getArray("foo").push(["bar"]);
|
||||
setTimeout(() => { // Wait for sending changes
|
||||
socket1.destroy();
|
||||
}, 200)
|
||||
}, 1100)
|
||||
|
||||
setTimeout(() => {
|
||||
const socket2 = newHocuspocusProviderWebsocket(t, server);
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket2,
|
||||
async onSynced() {
|
||||
setTimeout(() => {
|
||||
t.deepEqual(getCurrentValue(provider2.document), ["foo", "bar"], "Client 2 should see both changes");
|
||||
t.pass()
|
||||
resolve("done");
|
||||
}, 1500)
|
||||
},
|
||||
});
|
||||
}, 1600)
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
const getCurrentValue = (doc: Y.Doc) => doc.getArray("foo").toArray()
|
||||
|
||||
class SlowInMemoryStorage implements Extension {
|
||||
state: Uint8Array<ArrayBufferLike> | null = null
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const stateToSave = Y.encodeStateAsUpdate(data.document) // Capture state to save immediately
|
||||
await sleep(500) // Add pause to simulate long save
|
||||
this.state = stateToSave
|
||||
}
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
if (this.state) {
|
||||
Y.applyUpdate(data.document, this.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
365
tests/server/onTokenSync.ts
Normal file
365
tests/server/onTokenSync.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import test from 'ava'
|
||||
import type { onAuthenticatePayload, onTokenSyncPayload } from '@hocuspocus/server'
|
||||
import { Unauthorized } from '@hocuspocus/common'
|
||||
import {
|
||||
newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket, sleep,
|
||||
} from '../utils/index.ts'
|
||||
|
||||
// ============================================================================
|
||||
// PROVIDER SEND TOKEN TESTS
|
||||
// Provider calls sendToken() after onAuthenticated()
|
||||
// ============================================================================
|
||||
|
||||
test('provider sendToken: onTokenSync receives correct token from provider after authentication', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const expectedToken = 'UPDATED-TOKEN'
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true // Allow initial auth
|
||||
},
|
||||
async onTokenSync({ token }: onTokenSyncPayload) {
|
||||
t.is(token, expectedToken)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'INITIAL-TOKEN', // Initial token for auth
|
||||
onAuthenticated() {
|
||||
// Update the provider's token before sending
|
||||
provider.configuration.token = expectedToken
|
||||
// Provider sends updated token after authentication
|
||||
provider.sendToken() // This will send the current token
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('provider sendToken: executes onTokenSync from custom extension when provider sends token', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onAuthenticate() {
|
||||
return true
|
||||
}
|
||||
|
||||
async onTokenSync({ token }: onTokenSyncPayload) {
|
||||
t.is(token, 'SUPER-SECRET-TOKEN')
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'SUPER-SECRET-TOKEN',
|
||||
onAuthenticated() {
|
||||
provider.sendToken()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('provider sendToken: onTokenSync has access to full payload when provider sends token', async t => {
|
||||
const mockContext = { user: 123 }
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return mockContext
|
||||
},
|
||||
async onTokenSync({ context, connection, documentName, token }: onTokenSyncPayload) {
|
||||
t.deepEqual(context, mockContext)
|
||||
t.truthy(connection)
|
||||
t.is(documentName, 'hocuspocus-test')
|
||||
t.is(token, 'SUPER-SECRET-TOKEN')
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'SUPER-SECRET-TOKEN',
|
||||
onAuthenticated() {
|
||||
provider.sendToken()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('provider sendToken: onTokenSync works with multiple providers sending tokens', async t => {
|
||||
const doc1 = 'document1'
|
||||
const doc2 = 'document2'
|
||||
const token1 = 'TOKEN-1'
|
||||
const token2 = 'TOKEN-2'
|
||||
let completedCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true // Allow initial auth
|
||||
},
|
||||
async onTokenSync({ documentName, token }: onTokenSyncPayload) {
|
||||
// Direct verification based on document name
|
||||
if (documentName === doc1) {
|
||||
t.is(token, token1)
|
||||
} else if (documentName === doc2) {
|
||||
t.is(token, token2)
|
||||
}
|
||||
|
||||
completedCount++
|
||||
if (completedCount === 2) {
|
||||
resolve('done')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
// Create two providers for different documents
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: token1,
|
||||
name: doc1,
|
||||
onAuthenticated() {
|
||||
provider1.sendToken()
|
||||
},
|
||||
})
|
||||
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: token2,
|
||||
name: doc2,
|
||||
onAuthenticated() {
|
||||
provider2.sendToken()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// SERVER REQUEST TOKEN TESTS
|
||||
// Server calls connection.requestToken() after onAuthenticate() completes
|
||||
// ============================================================================
|
||||
|
||||
test('server requestToken: executes onTokenSync when server requests token after authentication', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true
|
||||
},
|
||||
async onTokenSync({ token }: onTokenSyncPayload) {
|
||||
t.is(token, 'SUPER-SECRET-TOKEN')
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'SUPER-SECRET-TOKEN',
|
||||
})
|
||||
|
||||
// Wait for provider to be authenticated to be able to find the connection
|
||||
await sleep(100)
|
||||
|
||||
const document = server.documents.get('hocuspocus-test')
|
||||
if (document) {
|
||||
const connection = document.connections.keys().next().value
|
||||
if (connection) {
|
||||
connection.requestToken()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('server requestToken: executes onTokenSync from custom extension when server requests token', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onAuthenticate() {
|
||||
return true
|
||||
}
|
||||
|
||||
async onTokenSync({ token }: onTokenSyncPayload) {
|
||||
t.is(token, 'SUPER-SECRET-TOKEN')
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'SUPER-SECRET-TOKEN',
|
||||
})
|
||||
|
||||
await sleep(100)
|
||||
|
||||
const document = server.documents.get('hocuspocus-test')
|
||||
if (document) {
|
||||
const connection = document.connections.keys().next().value
|
||||
if (connection) {
|
||||
connection.requestToken()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('server requestToken: onTokenSync receives correct token when server requests it', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const expectedToken = 'UPDATED-TOKEN'
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true
|
||||
},
|
||||
async onTokenSync({ token }: onTokenSyncPayload) {
|
||||
t.is(token, expectedToken)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'INITIAL-TOKEN',
|
||||
onAuthenticated() {
|
||||
// Update the provider's token after connection
|
||||
provider.configuration.token = expectedToken
|
||||
},
|
||||
})
|
||||
|
||||
await sleep(100)
|
||||
|
||||
// Now trigger token sync request from server
|
||||
const document = server.documents.get('hocuspocus-test')
|
||||
if (document) {
|
||||
const connection = document.connections.keys().next().value
|
||||
if (connection) {
|
||||
connection.requestToken()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('server requestToken: onTokenSync works with multiple documents when server requests tokens', async t => {
|
||||
const doc1 = 'document1'
|
||||
const doc2 = 'document2'
|
||||
const token1 = 'TOKEN-1'
|
||||
const token2 = 'TOKEN-2'
|
||||
let completedCount = 0
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true // Allow initial auth
|
||||
},
|
||||
async onTokenSync({ documentName, token }: onTokenSyncPayload) {
|
||||
// Direct verification based on document name
|
||||
if (documentName === doc1) {
|
||||
t.is(token, token1)
|
||||
} else if (documentName === doc2) {
|
||||
t.is(token, token2)
|
||||
}
|
||||
|
||||
completedCount++
|
||||
if (completedCount === 2) {
|
||||
resolve('done')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
// Create two providers for different documents
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: token1,
|
||||
name: doc1,
|
||||
})
|
||||
|
||||
const provider2 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: token2,
|
||||
name: doc2,
|
||||
})
|
||||
|
||||
// Wait for both authentications to complete
|
||||
await sleep(100)
|
||||
|
||||
// Now trigger token sync requests from server for both documents
|
||||
const doc1Connection = server.documents.get(doc1)?.connections.keys().next().value
|
||||
const doc2Connection = server.documents.get(doc2)?.connections.keys().next().value
|
||||
|
||||
if (doc1Connection) doc1Connection.requestToken()
|
||||
if (doc2Connection) doc2Connection.requestToken()
|
||||
})
|
||||
})
|
||||
|
||||
test('server requestToken: onTokenSync works with readonly connections when server requests token', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ connectionConfig }: onAuthenticatePayload) {
|
||||
connectionConfig.readOnly = true
|
||||
return true
|
||||
},
|
||||
async onTokenSync({ connection }: onTokenSyncPayload) {
|
||||
t.is(connection.readOnly, true)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'readonly-token',
|
||||
})
|
||||
|
||||
// Wait for authentication to complete first
|
||||
await sleep(100)
|
||||
|
||||
// Now trigger token sync request from server
|
||||
const document = server.documents.get('hocuspocus-test')
|
||||
if (document) {
|
||||
const connection = document.connections.keys().next().value
|
||||
if (connection) {
|
||||
connection.requestToken()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('server requestToken: failure of onTokenSync should close the connection', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true // Allow initial auth
|
||||
},
|
||||
async onTokenSync() {
|
||||
throw new Error()
|
||||
},
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
token: 'SUPER-SECRET-TOKEN',
|
||||
onClose({ event }) {
|
||||
t.is(event.reason, Unauthorized.reason)
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
await sleep(100)
|
||||
|
||||
const document = server.documents.get('hocuspocus-test')
|
||||
if (document) {
|
||||
const connection = document.connections.keys().next().value
|
||||
if (connection) {
|
||||
connection.requestToken()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
90
tests/server/onUpgrade.ts
Normal file
90
tests/server/onUpgrade.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
|
||||
|
||||
test('executes the onUpgrade callback', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onUpgrade() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('executes the onUpgrade callback from an extension', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomExtension {
|
||||
async onUpgrade() {
|
||||
t.pass()
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new CustomExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('has the server instance', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
|
||||
async onUpgrade({ instance }) {
|
||||
t.is(instance, server)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('has the request', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
|
||||
async onUpgrade({ request }) {
|
||||
t.is(request.url, '/')
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('has the socket', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
|
||||
async onUpgrade({ socket }) {
|
||||
t.truthy(socket)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
|
||||
test('has the head', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
|
||||
async onUpgrade({ head }) {
|
||||
t.truthy(head)
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
})
|
||||
})
|
||||
466
tests/server/openDirectConnection.ts
Normal file
466
tests/server/openDirectConnection.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import test from "ava";
|
||||
import * as Y from "yjs";
|
||||
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
|
||||
|
||||
test("rejects empty document name via direct connection", async (t) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
await t.throwsAsync(() => server.openDirectConnection(""), {
|
||||
message: "Document name must not be empty",
|
||||
});
|
||||
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
});
|
||||
|
||||
test("rejects whitespace-only document name via direct connection", async (t) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
await t.throwsAsync(() => server.openDirectConnection(" "), {
|
||||
message: "Document name must not be empty",
|
||||
});
|
||||
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
});
|
||||
|
||||
test("direct connection prevents document from being removed from memory", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
await server.openDirectConnection("hocuspocus-test");
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.configuration.websocketProvider.destroy();
|
||||
provider.destroy();
|
||||
|
||||
sleep(server.configuration.debounce + 50).then(() => {
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
resolve("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
test("direct connection works even if provider is connected", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getMap("config").set("a", "valueFromProvider");
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(150);
|
||||
|
||||
const directConnection =
|
||||
await server.openDirectConnection("hocuspocus-test");
|
||||
await directConnection.transact((doc) => {
|
||||
t.is("valueFromProvider", String(doc.getMap("config").get("a")));
|
||||
doc.getMap("config").set("b", "valueFromServerDirectConnection");
|
||||
});
|
||||
|
||||
await sleep(100);
|
||||
t.is(
|
||||
"valueFromServerDirectConnection",
|
||||
String(provider.document.getMap("config").get("b")),
|
||||
);
|
||||
|
||||
resolve(1);
|
||||
t.pass();
|
||||
});
|
||||
});
|
||||
|
||||
test("direct connection can apply yjsUpdate", async (t) => {
|
||||
const server = await newHocuspocus(t);
|
||||
const expectedContent = "<paragraph>Example Paragraph</paragraph>";
|
||||
let resolveProviderSynced!: () => void;
|
||||
const providerSynced = new Promise<void>((resolve) => {
|
||||
resolveProviderSynced = resolve;
|
||||
});
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
resolveProviderSynced();
|
||||
},
|
||||
});
|
||||
|
||||
await providerSynced;
|
||||
const fragment = provider.document.getXmlFragment("default");
|
||||
t.is(fragment.toJSON(), "");
|
||||
|
||||
let resolveUpdateApplied!: () => void;
|
||||
const updateApplied = new Promise<void>((resolve) => {
|
||||
resolveUpdateApplied = resolve;
|
||||
});
|
||||
const observer = () => {
|
||||
if (fragment.toJSON() === expectedContent) {
|
||||
fragment.unobserveDeep(observer);
|
||||
resolveUpdateApplied();
|
||||
}
|
||||
};
|
||||
fragment.observeDeep(observer);
|
||||
|
||||
const directConnection =
|
||||
await server.openDirectConnection("hocuspocus-test");
|
||||
await directConnection.transact((doc) => {
|
||||
Y.applyUpdate(
|
||||
doc,
|
||||
Y.encodeStateAsUpdate(
|
||||
TiptapTransformer.toYdoc({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Example Paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await updateApplied;
|
||||
t.is(fragment.toJSON(), expectedContent);
|
||||
await directConnection.disconnect();
|
||||
});
|
||||
|
||||
test("direct connection can transact", async (t) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
|
||||
t.is(direct.document?.getArray("test").toJSON()[0], "value");
|
||||
});
|
||||
|
||||
test("direct connection cannot transact once closed", async (t) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
await direct.disconnect();
|
||||
|
||||
try {
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
t.fail(
|
||||
"DirectConnection should throw an error when transacting on closed connection",
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "direct connection closed") {
|
||||
t.pass();
|
||||
} else {
|
||||
t.fail("unknown error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("if a direct connection closes, the document should be unloaded if there is no other connection left", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t);
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test1");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
|
||||
await direct.disconnect();
|
||||
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
|
||||
test("direct connection transact awaits until onStoreDocument has finished", async (t) => {
|
||||
let onStoreDocumentFinished = false;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onStoreDocument: async () => {
|
||||
onStoreDocumentFinished = false;
|
||||
await sleep(200);
|
||||
onStoreDocumentFinished = true;
|
||||
},
|
||||
});
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test2");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
|
||||
t.is(onStoreDocumentFinished, false);
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
|
||||
await direct.disconnect();
|
||||
t.is(onStoreDocumentFinished, true);
|
||||
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
t.is(onStoreDocumentFinished, true);
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
|
||||
test("direct connection transact awaits until onStoreDocument has finished, even if unloadImmediately=false", async (t) => {
|
||||
let onStoreDocumentFinished = false;
|
||||
let directConnDisconnecting = false;
|
||||
let storedAfterDisconnect = false;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
unloadImmediately: false,
|
||||
onStoreDocument: async () => {
|
||||
onStoreDocumentFinished = false;
|
||||
await sleep(200);
|
||||
onStoreDocumentFinished = true;
|
||||
|
||||
if (directConnDisconnecting) {
|
||||
storedAfterDisconnect = true;
|
||||
}
|
||||
},
|
||||
afterUnloadDocument: async (data) => {
|
||||
if (!storedAfterDisconnect) {
|
||||
t.fail("this shouldnt be called");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
|
||||
t.is(onStoreDocumentFinished, false);
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
provider.document.getMap("aaa").set("bb", "b");
|
||||
provider.disconnect();
|
||||
provider.configuration.websocketProvider.disconnect();
|
||||
|
||||
await sleep(100);
|
||||
|
||||
directConnDisconnecting = true;
|
||||
await direct.disconnect();
|
||||
t.is(onStoreDocumentFinished, true);
|
||||
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
|
||||
t.is(storedAfterDisconnect, true);
|
||||
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not unload document if an earlierly started onStoreDocument is still running", async (t) => {
|
||||
let onStoreDocumentStarted = 0;
|
||||
let onStoreDocumentFinished = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
unloadImmediately: false,
|
||||
debounce: 100,
|
||||
onStoreDocument: async () => {
|
||||
onStoreDocumentStarted++;
|
||||
if (onStoreDocumentStarted === 1) {
|
||||
// Simulate a long running onStoreDocument for the first debounced save
|
||||
await sleep(500);
|
||||
}
|
||||
onStoreDocumentFinished++;
|
||||
},
|
||||
afterUnloadDocument: async (data) => {},
|
||||
});
|
||||
|
||||
// Trigger a change, which will start a debounced onStoreDocument after 100ms
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
provider.document.getMap("aaa").set("bb", "b");
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
provider.on("synced", resolve);
|
||||
|
||||
if (!provider.unsyncedChanges) resolve("");
|
||||
});
|
||||
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
|
||||
// Wait for the debounced onStoreDocument to start
|
||||
await sleep(110);
|
||||
t.is(onStoreDocumentStarted, 1);
|
||||
t.is(onStoreDocumentFinished, 0);
|
||||
|
||||
// Open direct connection to prevent document from being unloaded
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 2);
|
||||
|
||||
// Close the websocket client
|
||||
provider.disconnect();
|
||||
provider.configuration.websocketProvider.disconnect();
|
||||
await sleep(50);
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
t.is(onStoreDocumentStarted, 1);
|
||||
t.is(onStoreDocumentFinished, 0);
|
||||
|
||||
direct.disconnect();
|
||||
await sleep(50);
|
||||
// Another save must not start before the first one has finished
|
||||
t.is(onStoreDocumentStarted, 1);
|
||||
t.is(onStoreDocumentFinished, 0);
|
||||
// Document must not be unloaded yet, because the first onStoreDocument is still running
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
|
||||
// Wait enough time to be sure the onStoreDocument has finished and ensure that the document was eventually unloaded
|
||||
await sleep(500);
|
||||
|
||||
// The second onStoreDocument triggered by direct.disconnect must have started and finished now
|
||||
t.is(onStoreDocumentStarted, 2);
|
||||
t.is(onStoreDocumentFinished, 2);
|
||||
// The document must have been unloaded now as well
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
});
|
||||
|
||||
test("creating a websocket connection after transact but before debounce interval doesnt create different docs", async (t) => {
|
||||
let onStoreDocumentFinished = false;
|
||||
let disconnected = false;
|
||||
let testDone = false;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
onStoreDocument: async () => {
|
||||
onStoreDocumentFinished = false;
|
||||
await sleep(200);
|
||||
onStoreDocumentFinished = true;
|
||||
},
|
||||
async afterUnloadDocument(data) {
|
||||
if (disconnected && !testDone) {
|
||||
t.fail("must not be called");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(server.getConnectionsCount(), 1);
|
||||
|
||||
t.is(onStoreDocumentFinished, false);
|
||||
await direct.transact((document) => {
|
||||
document.transact(() => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
}, "testOrigin");
|
||||
});
|
||||
|
||||
await direct.disconnect();
|
||||
t.is(onStoreDocumentFinished, true);
|
||||
disconnected = true;
|
||||
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
t.is(onStoreDocumentFinished, true);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server);
|
||||
|
||||
await sleep(server.configuration.debounce * 2);
|
||||
|
||||
testDone = true;
|
||||
resolve("done");
|
||||
});
|
||||
});
|
||||
|
||||
test("direct connection passes context", async (t) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onChange(x) {
|
||||
t.is(x.context.x, 123);
|
||||
t.pass();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test", {
|
||||
x: 123,
|
||||
});
|
||||
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
|
||||
t.is(direct.document?.getArray("test").toJSON()[0], "value");
|
||||
});
|
||||
});
|
||||
|
||||
test("disconnect({ unloadImmediately: false }) keeps the document warm and coalesces stores", async (t) => {
|
||||
let storeCount = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
debounce: 100,
|
||||
maxDebounce: 500,
|
||||
onStoreDocument: async () => {
|
||||
storeCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await server.openDirectConnection("hocuspocus-test");
|
||||
await first.transact((document) => {
|
||||
document.getArray("test").insert(0, ["a"]);
|
||||
});
|
||||
await first.disconnect({ unloadImmediately: false });
|
||||
|
||||
// The document stays in memory and nothing is persisted yet.
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(storeCount, 0);
|
||||
|
||||
// A follow-up direct connection reuses the warm document.
|
||||
const second = await server.openDirectConnection("hocuspocus-test");
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
await second.transact((document) => {
|
||||
document.getArray("test").insert(1, ["b"]);
|
||||
});
|
||||
await second.disconnect({ unloadImmediately: false });
|
||||
|
||||
t.is(server.getDocumentsCount(), 1);
|
||||
t.is(storeCount, 0);
|
||||
|
||||
// After the debounce window the store flushes once (coalesced) and the
|
||||
// document is unloaded.
|
||||
await sleep(server.configuration.maxDebounce + server.configuration.debounce + 100);
|
||||
t.is(storeCount, 1);
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
});
|
||||
|
||||
test("disconnect() without options still persists and unloads immediately", async (t) => {
|
||||
let storeCount = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
onStoreDocument: async () => {
|
||||
storeCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const direct = await server.openDirectConnection("hocuspocus-test");
|
||||
await direct.transact((document) => {
|
||||
document.getArray("test").insert(0, ["value"]);
|
||||
});
|
||||
await direct.disconnect();
|
||||
|
||||
t.is(storeCount, 1);
|
||||
t.is(server.getConnectionsCount(), 0);
|
||||
t.is(server.getDocumentsCount(), 0);
|
||||
});
|
||||
101
tests/server/providerVersion.ts
Normal file
101
tests/server/providerVersion.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
connectedPayload,
|
||||
onAuthenticatePayload,
|
||||
onConnectPayload,
|
||||
} from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
newHocuspocusProviderWebsocket,
|
||||
} from "../utils/index.ts";
|
||||
|
||||
test("onAuthenticate receives providerVersion", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
|
||||
t.is(typeof providerVersion, "string");
|
||||
t.not(providerVersion, null);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "test-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("onConnect receives providerVersion", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onConnect({ providerVersion }: onConnectPayload) {
|
||||
t.is(typeof providerVersion, "string");
|
||||
t.not(providerVersion, null);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "test-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("connected receives providerVersion and it is set on the connection", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async connected({
|
||||
providerVersion,
|
||||
connection,
|
||||
}: connectedPayload) {
|
||||
t.is(typeof providerVersion, "string");
|
||||
t.not(providerVersion, null);
|
||||
t.is(connection.providerVersion, providerVersion);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "test-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("providerVersion is a non-empty string", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
|
||||
t.is(typeof providerVersion, "string");
|
||||
t.truthy(providerVersion!.length > 0);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
token: "test-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("providerVersion is the same across multiplexed documents", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const versions: string[] = [];
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ providerVersion }: onAuthenticatePayload) {
|
||||
versions.push(providerVersion!);
|
||||
if (versions.length === 2) {
|
||||
t.is(versions[0], versions[1]);
|
||||
t.not(versions[0], null);
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const ws = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
newHocuspocusProvider(t, server, { name: "doc1", token: "t1" }, {}, ws);
|
||||
newHocuspocusProvider(t, server, { name: "doc2", token: "t2" }, {}, ws);
|
||||
});
|
||||
});
|
||||
329
tests/server/sessionAwareness.ts
Normal file
329
tests/server/sessionAwareness.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { parseRoutingKey } from "@hocuspocus/common";
|
||||
import type { onAuthenticatePayload, connectedPayload } from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import { readVarString, createDecoder } from "lib0/decoding";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
newHocuspocusProviderWebsocket,
|
||||
} from "../utils/index.ts";
|
||||
import { retryableAssertion } from "../utils/retryableAssertion.ts";
|
||||
|
||||
test("sessionAwareness: two providers with same doc name both connect successfully", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let connectedCount = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider1 = newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "token-1",
|
||||
name: "shared-doc",
|
||||
sessionAwareness: true,
|
||||
onAuthenticated() {
|
||||
connectedCount++;
|
||||
if (connectedCount === 2) {
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const provider2 = newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "token-2",
|
||||
name: "shared-doc",
|
||||
sessionAwareness: true,
|
||||
onAuthenticated() {
|
||||
connectedCount++;
|
||||
if (connectedCount === 2) {
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
t.truthy(provider1);
|
||||
t.truthy(provider2);
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: auth failure isolation - provider A fails, provider B succeeds", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate({ token }: onAuthenticatePayload) {
|
||||
if (token === "bad-token") {
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const providerFail = newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "bad-token",
|
||||
name: "shared-doc",
|
||||
sessionAwareness: true,
|
||||
onAuthenticated() {
|
||||
t.fail("providerFail should not authenticate");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const providerOK = newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "good-token",
|
||||
name: "shared-doc",
|
||||
sessionAwareness: true,
|
||||
onAuthenticationFailed() {
|
||||
t.fail("providerOK should not fail auth");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(providerFail.isAuthenticated, false);
|
||||
tt.is(providerOK.isAuthenticated, true);
|
||||
tt.is(server.getDocumentsCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: false - two providers with same name on same socket throws when first is authenticated", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider1 = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "token",
|
||||
name: "same-doc",
|
||||
sessionAwareness: false,
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(provider1.isAuthenticated, true);
|
||||
});
|
||||
|
||||
// Now that provider1 is authenticated, attaching a second with the same name should throw
|
||||
t.throws(() => {
|
||||
newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "token",
|
||||
name: "same-doc",
|
||||
sessionAwareness: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: connection has correct sessionId", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
async connected({ connection }: connectedPayload) {
|
||||
t.truthy(connection.sessionId);
|
||||
t.is(typeof connection.sessionId, "string");
|
||||
t.truthy(connection.sessionId!.length > 0);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "test-token",
|
||||
name: "session-doc",
|
||||
sessionAwareness: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: connection has correct providerVersion", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
async connected({ connection }: connectedPayload) {
|
||||
t.is(typeof connection.providerVersion, "string");
|
||||
t.truthy(connection.providerVersion!.length > 0);
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "test-token",
|
||||
name: "session-doc",
|
||||
sessionAwareness: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: all outgoing messages from provider include sessionId in routing key", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const sentMessages: Uint8Array[] = [];
|
||||
const originalSend = socket.webSocket!.send.bind(socket.webSocket!);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "test-token",
|
||||
name: "session-doc",
|
||||
sessionAwareness: true,
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(provider.isSynced, true);
|
||||
});
|
||||
|
||||
// Monkey-patch send to capture outgoing messages
|
||||
socket.webSocket!.send = (data: any) => {
|
||||
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
|
||||
sentMessages.push(new Uint8Array(data));
|
||||
}
|
||||
originalSend(data);
|
||||
};
|
||||
|
||||
// Trigger a document update which causes the provider to send an UpdateMessage
|
||||
provider.document.getMap("test").set("key", "value");
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.true(sentMessages.length > 0, "should have captured outgoing messages");
|
||||
|
||||
for (const msg of sentMessages) {
|
||||
const decoder = createDecoder(msg);
|
||||
const routingKey = readVarString(decoder);
|
||||
const { documentName, sessionId } = parseRoutingKey(routingKey);
|
||||
tt.is(documentName, "session-doc", `message routing key should contain correct document name, got: ${routingKey}`);
|
||||
tt.truthy(sessionId, `every outgoing message should include a sessionId in the routing key, got: ${routingKey}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: SyncStep2 reply includes sessionId in routing key", async (t) => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
token: "test-token",
|
||||
name: "sync-reply-doc",
|
||||
sessionAwareness: true,
|
||||
});
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
tt.is(provider.isSynced, true);
|
||||
});
|
||||
|
||||
// Capture all outgoing messages
|
||||
const sentMessages: Uint8Array[] = [];
|
||||
const originalSend = socket.webSocket!.send.bind(socket.webSocket!);
|
||||
socket.webSocket!.send = (data: any) => {
|
||||
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
|
||||
sentMessages.push(new Uint8Array(data));
|
||||
}
|
||||
originalSend(data);
|
||||
};
|
||||
|
||||
// Force a sync which triggers SyncStep1 -> server replies with SyncStep1 -> provider replies with SyncStep2
|
||||
provider.forceSync();
|
||||
|
||||
await retryableAssertion(t, (tt) => {
|
||||
// We expect at least the SyncStep1 from forceSync plus the SyncStep2 reply
|
||||
tt.true(sentMessages.length >= 2, `should have at least 2 messages, got ${sentMessages.length}`);
|
||||
|
||||
for (const msg of sentMessages) {
|
||||
const decoder = createDecoder(msg);
|
||||
const routingKey = readVarString(decoder);
|
||||
const { sessionId } = parseRoutingKey(routingKey);
|
||||
tt.truthy(sessionId, `message should include sessionId, got routing key: ${routingKey}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("sessionAwareness: providers with different doc names still work without sessionAwareness", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let connectedCount = 0;
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server);
|
||||
|
||||
newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "token-1",
|
||||
name: "doc-1",
|
||||
onAuthenticated() {
|
||||
connectedCount++;
|
||||
if (connectedCount === 2) {
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
newHocuspocusProvider(
|
||||
t,
|
||||
server,
|
||||
{
|
||||
websocketProvider: socket,
|
||||
token: "token-2",
|
||||
name: "doc-2",
|
||||
onAuthenticated() {
|
||||
connectedCount++;
|
||||
if (connectedCount === 2) {
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
t.pass();
|
||||
});
|
||||
});
|
||||
227
tests/server/unauthenticatedQueueLimit.ts
Normal file
227
tests/server/unauthenticatedQueueLimit.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import test from 'ava'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { writeAuthentication } from '@hocuspocus/common'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
|
||||
const MessageType = {
|
||||
Auth: 2,
|
||||
Stateless: 5,
|
||||
}
|
||||
|
||||
const buildStatelessFrame = (documentName: string, payloadSize: number): Uint8Array => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, MessageType.Stateless)
|
||||
encoding.writeVarString(encoder, 'A'.repeat(payloadSize))
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, MessageType.Auth)
|
||||
writeAuthentication(encoder, token)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
const buildCloseFrame = (documentName: string): Uint8Array => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, 7) // MessageType.CLOSE
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.addEventListener('open', () => resolve(ws), { once: true })
|
||||
ws.addEventListener('error', (event) => reject(event), { once: true })
|
||||
})
|
||||
|
||||
// Wait for the server to close the socket, returning the close code.
|
||||
const waitForClose = (ws: WebSocket, timeoutMs = 5000): Promise<number> => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('connection was not closed by the server')), timeoutMs)
|
||||
ws.addEventListener('close', (event) => {
|
||||
clearTimeout(timer)
|
||||
resolve(event.code)
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
test('closes an unauthenticated connection that exceeds the pre-auth message-count limit', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
maxUnauthenticatedQueueMessages: 5,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
const closed = waitForClose(attacker)
|
||||
|
||||
// Flood many small non-Auth frames before authenticating. The server must
|
||||
// stop queuing and close the connection once the count limit is exceeded.
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
attacker.send(buildStatelessFrame('queue-count-doc', 16))
|
||||
}
|
||||
|
||||
const code = await closed
|
||||
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
|
||||
})
|
||||
|
||||
test('closes an unauthenticated connection that exceeds the pre-auth byte limit', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
// 256 KB total budget across the unauthenticated queue.
|
||||
maxUnauthenticatedQueueSize: 256 * 1024,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
const closed = waitForClose(attacker)
|
||||
|
||||
// Each frame carries ~64 KB; the 5th frame crosses the 256 KB budget.
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
attacker.send(buildStatelessFrame('queue-bytes-doc', 64 * 1024))
|
||||
}
|
||||
|
||||
const code = await closed
|
||||
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
|
||||
})
|
||||
|
||||
test('closes an unauthenticated connection that opens too many pending documents', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
maxPendingDocuments: 3,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
const closed = waitForClose(attacker)
|
||||
|
||||
// Spread a single small frame across many distinct document keys. A per-key
|
||||
// limit alone would not catch this amplification; a per-connection cap does.
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
attacker.send(buildStatelessFrame(`pending-doc-${i}`, 16))
|
||||
}
|
||||
|
||||
const code = await closed
|
||||
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
|
||||
})
|
||||
|
||||
test('closes a continuously-sending unauthenticated connection once the pre-auth timeout elapses', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
timeout: 1000,
|
||||
// Keep queue limits high so the timeout — not the queue cap — is what fires.
|
||||
maxUnauthenticatedQueueMessages: 1_000_000,
|
||||
maxUnauthenticatedQueueSize: 1024 * 1024 * 1024,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
|
||||
const closed = waitForClose(attacker, 6000)
|
||||
|
||||
// Keep refreshing activity with tiny frames. The pre-auth deadline must not
|
||||
// be reset by inbound traffic, so the connection still closes.
|
||||
const interval = setInterval(() => {
|
||||
if (attacker.readyState === WebSocket.OPEN) {
|
||||
attacker.send(buildStatelessFrame('timeout-doc', 8))
|
||||
}
|
||||
}, 200)
|
||||
t.teardown(() => {
|
||||
clearInterval(interval)
|
||||
attacker.close()
|
||||
})
|
||||
|
||||
const code = await closed
|
||||
clearInterval(interval)
|
||||
t.is(code, 4408, 'connection should be closed with ConnectionTimeout (4408)')
|
||||
})
|
||||
|
||||
test('counts in-flight authentications toward the pending-document limit', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
maxPendingDocuments: 3,
|
||||
// Never resolves, so every document stays in the "authenticating" state.
|
||||
// Sending Auth first marks a document as establishing but keeps its hook
|
||||
// payload alive while onAuthenticate runs — this must still count.
|
||||
onAuthenticate: () => new Promise(() => {}),
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
const closed = waitForClose(attacker)
|
||||
|
||||
// Send Auth as the FIRST frame for many distinct documents.
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
attacker.send(buildAuthFrame(`auth-first-doc-${i}`, ''))
|
||||
}
|
||||
|
||||
const code = await closed
|
||||
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
|
||||
})
|
||||
|
||||
test('does not close an authenticated connection that has closed all of its documents', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
timeout: 600,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
// Authenticate a document (no onAuthenticate hook => allowed).
|
||||
const authenticated = new Promise<void>((resolve) => {
|
||||
attacker.addEventListener('message', () => resolve(), { once: true })
|
||||
})
|
||||
attacker.send(buildAuthFrame('soon-empty-doc', ''))
|
||||
await authenticated
|
||||
|
||||
let closedCode: number | undefined
|
||||
attacker.addEventListener('close', (event) => { closedCode = event.code }, { once: true })
|
||||
|
||||
// Keep the socket alive with periodic frames so `lastMessageReceivedAt` stays
|
||||
// fresh. Halfway through, close the only document, leaving zero established
|
||||
// document connections. Because the connection already authenticated, the idle
|
||||
// check must keep using `lastMessageReceivedAt` rather than the original open
|
||||
// time — so the socket must survive past `timeout` measured from open.
|
||||
const interval = setInterval(() => {
|
||||
if (attacker.readyState === WebSocket.OPEN) {
|
||||
attacker.send(buildStatelessFrame('keepalive-doc', 8))
|
||||
}
|
||||
}, 150)
|
||||
t.teardown(() => clearInterval(interval))
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
attacker.send(buildCloseFrame('soon-empty-doc'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
|
||||
clearInterval(interval)
|
||||
t.is(closedCode, undefined, 'authenticated connection must not be closed by the pre-auth deadline')
|
||||
})
|
||||
|
||||
test('does not close an authenticated connection that later exceeds the pre-auth limits', async t => {
|
||||
const server = await newHocuspocus(t, {
|
||||
maxUnauthenticatedQueueMessages: 5,
|
||||
maxUnauthenticatedQueueSize: 64 * 1024,
|
||||
})
|
||||
|
||||
const attacker = await openRawSocket(server.server!.webSocketURL)
|
||||
t.teardown(() => attacker.close())
|
||||
|
||||
// Authenticate first (no onAuthenticate hook => allowed).
|
||||
const authenticated = new Promise<void>((resolve) => {
|
||||
attacker.addEventListener('message', () => resolve(), { once: true })
|
||||
})
|
||||
attacker.send(buildAuthFrame('authenticated-doc', ''))
|
||||
await authenticated
|
||||
|
||||
// After auth, large/many frames flow through the established connection and
|
||||
// are not subject to the pre-auth queue caps.
|
||||
let closedEarly = false
|
||||
attacker.addEventListener('close', () => { closedEarly = true }, { once: true })
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
attacker.send(buildStatelessFrame('authenticated-doc', 64 * 1024))
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
t.false(closedEarly, 'authenticated connection must not be closed by pre-auth queue limits')
|
||||
})
|
||||
38
tests/server/websocketError.ts
Normal file
38
tests/server/websocketError.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from 'ava'
|
||||
import type { onAuthenticatePayload } from '@hocuspocus/server'
|
||||
import { newHocuspocus, newHocuspocusProvider, newHocuspocusProviderWebsocket } from '../utils/index.ts'
|
||||
|
||||
test('does not crash when malformed message is sent pre-authentication', async t => {
|
||||
await new Promise(async resolve => {
|
||||
const server = await newHocuspocus(t, {
|
||||
async onAuthenticate(data: onAuthenticatePayload) {
|
||||
return new Promise(async resolve => {
|
||||
setTimeout(resolve, 2000)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const socket = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
websocketProvider: socket,
|
||||
onClose({ event }) {
|
||||
t.is(event.code, 4401)
|
||||
clearInterval(interval)
|
||||
provider.destroy()
|
||||
},
|
||||
onDestroy() {
|
||||
t.pass()
|
||||
resolve(true)
|
||||
},
|
||||
})
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (socket.webSocket) {
|
||||
socket.webSocket.send('ϩ') // eslint-disable-line
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user