Skip to content

Commit 0cae5d0

Browse files
committed
fix: send author-attributed inserts and preserve trailing newline
Etherpad's USER_CHANGES handler now (post-2.7.x, see ether/etherpad#7773) rejects two kinds of malformed inserts: 1. `+` ops with no `author` attribute — they grow pad.atext.text without contributing matching markers to pad.atext.attribs, leaving the two iterables out of sync and breaking setDocAText reconciliation on every later client load. 2. USER_CHANGES whose application would leave the pad text not ending with '\n' — the browser's line assembler asserts on docs that don't end with '\n' and the session dies. Both were silently produced by `ee.append()`: it called `makeSplice(text, text.length, 0, ins)` (insert at very end, no attribs, no pool). When the host pad starts as `'\n'`, appending `'1'` produced `'\n1'` (no trailing newline) and the changeset's insert carried no author attribute. Fix: * Capture `userId` from CLIENT_VARS and use it as the author attribute on subsequent inserts. * Splice at `text.length - 1` so the insert lands before the trailing '\n' and the invariant holds. * Build the changeset against `padState.apool`, then `moveOpsToNewPool` into a fresh wire apool so the server can resolve the `*N` slot numbers we send. Backward-compatible: pre-hardening Etherpad servers accept the new shape too (it's the same shape the standard JS web client sends). Refs ether/etherpad#7773
1 parent 592fa8c commit 0cae5d0

2 files changed

Lines changed: 24 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "etherpad-cli-client",
33
"description": "Node Client for Etherpad",
4-
"version": "4.0.2",
4+
"version": "4.0.3",
55
"type": "module",
66
"author": {
77
"name": "John McLear",

src/index.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type PadClient = EventEmitter & {
3838
type ClientVarsMessage = {
3939
type: 'CLIENT_VARS';
4040
data: {
41+
userId?: string;
4142
collab_client_vars: {
4243
initialAttributedText: AText;
4344
apool: JsonableAttributePool;
@@ -108,6 +109,7 @@ const isDisconnectMessage = (value: unknown): value is DisconnectMessage =>
108109

109110
export const connect = (host?: string): PadClient => {
110111
const ee = new EventEmitter() as PadClient;
112+
let authorId: string | null = null;
111113
const padState: PadState = {
112114
host: '',
113115
path: '',
@@ -198,6 +200,7 @@ export const connect = (host?: string): PadClient => {
198200
padState.atext = obj.data.collab_client_vars.initialAttributedText;
199201
padState.apool = new AttributePool().fromJsonable(obj.data.collab_client_vars.apool);
200202
padState.baseRev = obj.data.collab_client_vars.rev;
203+
if (typeof obj.data.userId === 'string') authorId = obj.data.userId;
201204
ee.emit('connected', padState);
202205
} else if (isNewChangesMessage(obj)) {
203206
if (obj.data.newRev <= padState.baseRev) return;
@@ -240,16 +243,31 @@ export const connect = (host?: string): PadClient => {
240243
};
241244

242245
ee.append = (text: string) => {
243-
const newChangeset = Changeset.makeSplice(
244-
padState.atext.text, padState.atext.text.length, 0, text);
246+
// Insert just before the trailing '\n' so the pad's "doc always ends
247+
// with \n" invariant is preserved. Etherpad's server (post-2.7.x)
248+
// rejects USER_CHANGES whose application would leave the doc without
249+
// a trailing newline, and tags inserts with no `author` attribute as
250+
// bad changesets — both produced silent disconnects with the previous
251+
// append-at-text.length / no-attribs behaviour.
252+
const insertPos = Math.max(0, padState.atext.text.length - 1);
253+
const attribs: Array<[string, string]> | undefined =
254+
authorId ? [['author', authorId]] : undefined;
255+
const localChangeset = Changeset.makeSplice(
256+
padState.atext.text, insertPos, 0, text, attribs, padState.apool);
245257
const newRev = padState.baseRev;
246-
padState.atext = Changeset.applyToAText(newChangeset, padState.atext, padState.apool) as AText;
258+
padState.atext = Changeset.applyToAText(
259+
localChangeset, padState.atext, padState.apool) as AText;
260+
// Build a minimal wire pool containing only the attributes referenced
261+
// by this changeset so the server can resolve our `*N` slot numbers.
262+
const wireApool = new AttributePool();
263+
const wireChangeset = Changeset.moveOpsToNewPool(
264+
localChangeset, padState.apool, wireApool);
247265
const msg: PendingMessage = {
248266
component: 'pad',
249267
type: 'USER_CHANGES',
250268
baseRev: newRev,
251-
changeset: newChangeset,
252-
apool: new AttributePool().toJsonable(),
269+
changeset: wireChangeset,
270+
apool: wireApool.toJsonable(),
253271
};
254272
sendMessage(msg);
255273
};

0 commit comments

Comments
 (0)