Skip to content

Commit 1597da8

Browse files
authored
feat(server): add beforeHandleAwareness hook (#1096)
Fires after decoding an inbound awareness message and before applyAwarenessUpdate, exposing per-client states as a mutable Map<clientId, state>. Mutations rewrite the broadcast; throwing rejects the update. Extensions chain naturally and run before the configuration-level hook. Payload shape matches onAwarenessUpdatePayload: transactionOrigin plus connection? convenience shortcut.
1 parent bbabe6b commit 1597da8

5 files changed

Lines changed: 371 additions & 4 deletions

File tree

packages/server/src/Document.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export class Document extends Doc {
2121
// eslint-disable-next-line @typescript-eslint/no-empty-function
2222
onUpdate: (document: Document, origin: unknown, update: Uint8Array) => {},
2323
beforeBroadcastStateless: (document: Document, stateless: string) => {},
24+
beforeHandleAwareness: (
25+
document: Document,
26+
states: Map<number, Record<string, any>>,
27+
transactionOrigin: unknown,
28+
) => Promise.resolve(),
2429
};
2530

2631
connections: Map<
@@ -100,6 +105,27 @@ export class Document extends Doc {
100105
return this;
101106
}
102107

108+
/**
109+
* Set a callback that will be triggered before an inbound awareness update
110+
* is applied to this document's awareness state. The callback receives the
111+
* document, the decoded per-client states as a mutable `Map`, and the
112+
* `TransactionOrigin` that will be forwarded to `applyAwarenessUpdate`.
113+
* Use `isTransactionOrigin(origin)` to discriminate sources. Mutate the
114+
* map in place (set/delete/field changes) to rewrite the update, or throw
115+
* to reject it entirely.
116+
*/
117+
beforeHandleAwareness(
118+
callback: (
119+
document: Document,
120+
states: Map<number, Record<string, any>>,
121+
transactionOrigin: unknown,
122+
) => Promise<any>,
123+
): Document {
124+
this.callbacks.beforeHandleAwareness = callback;
125+
126+
return this;
127+
}
128+
103129
/**
104130
* Register a connection and a set of clients on this document keyed by the
105131
* underlying websocket connection

packages/server/src/Hocuspocus.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class Hocuspocus<Context = any> {
4646
onConnect: () => new Promise((r) => r(null)),
4747
connected: () => new Promise((r) => r(null)),
4848
beforeHandleMessage: () => new Promise((r) => r(null)),
49+
beforeHandleAwareness: () => new Promise<void>((r) => r()),
4950
beforeSync: () => new Promise((r) => r(null)),
5051
beforeBroadcastStateless: () => new Promise((r) => r(null)),
5152
onStateless: () => new Promise((r) => r(null)),
@@ -112,6 +113,7 @@ export class Hocuspocus<Context = any> {
112113
onLoadDocument: this.configuration.onLoadDocument,
113114
afterLoadDocument: this.configuration.afterLoadDocument,
114115
beforeHandleMessage: this.configuration.beforeHandleMessage,
116+
beforeHandleAwareness: this.configuration.beforeHandleAwareness,
115117
beforeBroadcastStateless: this.configuration.beforeBroadcastStateless,
116118
beforeSync: this.configuration.beforeSync,
117119
onStateless: this.configuration.onStateless,
@@ -436,6 +438,31 @@ export class Hocuspocus<Context = any> {
436438
},
437439
);
438440

441+
document.beforeHandleAwareness((document, states, transactionOrigin) => {
442+
const connection =
443+
isTransactionOrigin(transactionOrigin) &&
444+
transactionOrigin.source === "connection"
445+
? transactionOrigin.connection
446+
: undefined;
447+
const request = connection?.request;
448+
return this.hooks("beforeHandleAwareness", {
449+
awareness: document.awareness,
450+
clientsCount: document.getConnectionsCount(),
451+
context: connection?.context,
452+
document,
453+
documentName: document.name,
454+
instance: this,
455+
requestHeaders: request?.headers ?? new Headers(),
456+
requestParameters: request
457+
? getParameters(request)
458+
: new URLSearchParams(),
459+
socketId: connection?.socketId ?? "",
460+
transactionOrigin,
461+
connection,
462+
states,
463+
});
464+
});
465+
439466
document.awareness.on(
440467
"update",
441468
(update: AwarenessUpdate, origin: unknown) => {

packages/server/src/MessageReceiver.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { AuthMessageType } from "@hocuspocus/common";
22
import * as decoding from "lib0/decoding";
33
import { readVarString } from "lib0/decoding";
4-
import { applyAwarenessUpdate } from "y-protocols/awareness";
4+
import {
5+
Awareness,
6+
applyAwarenessUpdate,
7+
encodeAwarenessUpdate,
8+
} from "y-protocols/awareness";
59
import {
610
messageYjsSyncStep1,
711
messageYjsSyncStep2,
@@ -66,19 +70,36 @@ export class MessageReceiver {
6670
break;
6771
}
6872
case MessageType.Awareness: {
73+
let update = message.readVarUint8Array();
74+
6975
const origin: TransactionOrigin = connection
7076
? ({
7177
source: "connection",
7278
connection,
7379
} satisfies ConnectionTransactionOrigin)
7480
: (this.defaultTransactionOrigin ?? { source: "local" });
7581

76-
applyAwarenessUpdate(
77-
document.awareness,
78-
message.readVarUint8Array(),
82+
// Decode the inbound update into a scratch Awareness so the hook
83+
// chain sees a high-level Map<clientId, state>. Mutations to that
84+
// map (including `set`, `delete`, and field changes on each state
85+
// object) are picked up by the re-encode below and forwarded as
86+
// the broadcast payload. Hooks may also throw to reject the
87+
// update entirely.
88+
const scratch = new Awareness(new Y.Doc());
89+
applyAwarenessUpdate(scratch, update, null);
90+
91+
await document.callbacks.beforeHandleAwareness(
92+
document,
93+
scratch.getStates(),
7994
origin,
8095
);
8196

97+
update = encodeAwarenessUpdate(scratch, [
98+
...scratch.getStates().keys(),
99+
]);
100+
101+
applyAwarenessUpdate(document.awareness, update, origin);
102+
82103
break;
83104
}
84105
case MessageType.QueryAwareness: {

packages/server/src/types.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ export interface Extension<Context = any> {
9898
onLoadDocument?(data: onLoadDocumentPayload<Context>): Promise<any>;
9999
afterLoadDocument?(data: afterLoadDocumentPayload<Context>): Promise<any>;
100100
beforeHandleMessage?(data: beforeHandleMessagePayload<Context>): Promise<any>;
101+
/**
102+
* Fired before an inbound awareness update is applied to the document's
103+
* awareness state. The hook receives the decoded per-client `states` as a
104+
* mutable `Map` keyed by Yjs clientId. Mutate the map and the contained
105+
* state objects in place to rewrite fields, drop peers (`states.delete`),
106+
* or add synthetic ones (`states.set`); mutations are reflected in the
107+
* broadcast. Throw to reject the update without applying anything.
108+
*
109+
* Multiple extensions chain naturally: each extension sees the map as
110+
* mutated by previous extensions and can mutate it further.
111+
*/
112+
beforeHandleAwareness?(
113+
data: beforeHandleAwarenessPayload<Context>,
114+
): Promise<any>;
101115
beforeSync?(data: beforeSyncPayload<Context>): Promise<any>;
102116
beforeBroadcastStateless?(
103117
data: beforeBroadcastStatelessPayload,
@@ -126,6 +140,7 @@ export type HookName =
126140
| "onLoadDocument"
127141
| "afterLoadDocument"
128142
| "beforeHandleMessage"
143+
| "beforeHandleAwareness"
129144
| "beforeBroadcastStateless"
130145
| "beforeSync"
131146
| "onStateless"
@@ -151,6 +166,7 @@ export type HookPayloadByName<Context = any> = {
151166
onLoadDocument: onLoadDocumentPayload<Context>;
152167
afterLoadDocument: afterLoadDocumentPayload<Context>;
153168
beforeHandleMessage: beforeHandleMessagePayload<Context>;
169+
beforeHandleAwareness: beforeHandleAwarenessPayload<Context>;
154170
beforeBroadcastStateless: beforeBroadcastStatelessPayload;
155171
beforeSync: beforeSyncPayload<Context>;
156172
onStateless: onStatelessPayload;
@@ -326,6 +342,44 @@ export interface beforeHandleMessagePayload<Context = any> {
326342
connection: Connection<Context>;
327343
}
328344

345+
export interface beforeHandleAwarenessPayload<Context = any> {
346+
awareness: Awareness;
347+
clientsCount: number;
348+
/**
349+
* Connection context populated by `onAuthenticate`. `undefined` when the
350+
* update did not originate from a client connection (e.g. server-internal
351+
* writes via `DirectConnection`).
352+
*/
353+
context: Context | undefined;
354+
document: Document;
355+
documentName: string;
356+
instance: Hocuspocus;
357+
requestHeaders: Headers;
358+
requestParameters: URLSearchParams;
359+
/**
360+
* Per-client awareness states decoded from the inbound update, keyed by
361+
* Yjs clientId. Mutate this map in place to rewrite the update: change
362+
* fields on a state object, `states.delete(clientId)` to drop a peer, or
363+
* `states.set(clientId, ...)` to add or replace one. The encoded update
364+
* sent to peers reflects whatever the map looks like after every hook in
365+
* the chain has run.
366+
*/
367+
states: Map<number, Record<string, any>>;
368+
socketId: string;
369+
/**
370+
* The `TransactionOrigin` that will be passed to `applyAwarenessUpdate`.
371+
* Use `isTransactionOrigin(origin)` to discriminate sources. Matches the
372+
* `transactionOrigin` shape of `onAwarenessUpdatePayload`.
373+
*/
374+
transactionOrigin: unknown;
375+
/**
376+
* Convenience shortcut: `origin.connection` when `transactionOrigin` is a
377+
* `ConnectionTransactionOrigin`, otherwise `undefined`. Matches the
378+
* `connection?` shape of `onAwarenessUpdatePayload`.
379+
*/
380+
connection?: Connection<Context>;
381+
}
382+
329383
export interface beforeSyncPayload<Context = any> {
330384
clientsCount: number;
331385
context: Context;

0 commit comments

Comments
 (0)