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

This commit is contained in:
upstream-mirror
2026-09-15 17:21:35 +05:00
commit e0c588ca1c
265 changed files with 55259 additions and 0 deletions

61
tests/server/address.ts Normal file
View 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!')
})

View 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)
},
})
})
})

View 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('')
})
})

View 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'])
},
})
})
})

View 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)
})
})

View 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)
},
})
})
})

View 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)
})
})

View 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
View 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)
})
})

View 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);
});
});

View 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)
}
}
})

View 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',
)
})

View 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
View 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
View 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
View 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)
})
})

View 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)
})
})

View 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
View 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!')
})

View 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,
});
});
});

View 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
View 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
View 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)
})
})

View 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
View 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
View 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("");
});
});

View 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
View 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')
},
})
})
})

View 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
View 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
View 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')
},
})
})
})

View 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
View 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
View 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)
})
})

View 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);
});

View 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);
});
});

View 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();
});
});

View 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')
})

View 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)
})
})