From cb75fdfade100004b2f6d89fb6f85af2a32e065e Mon Sep 17 00:00:00 2001 From: jibize Date: Wed, 26 Aug 2026 23:11:47 +0200 Subject: [PATCH] feat(loro-websocket): surface JoinError code and appCode on the join rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleJoinError` rejected with a plain `Error` whose only content was the formatted string `Join failed: - `, so an application had to parse that text to tell one refusal from another. The `app_error` (0x7F) code exists precisely so a server can attach an application-defined `app_code` (protocol.md: "Extra `varString app_code` (free-form, e.g., `quota_exceeded`)"), and the decoder already reads it — but the client dropped it before the value reached the caller. Reject with a `JoinFailedError` carrying `code`, `appCode`, `roomId` and `crdt`. The `message` text is byte-identical to before, so consumers that still match on the string keep working. This is the client-side half of the optional hook protocol.md already describes: `onError({ roomId, kind, code, message, app_code? })`. --- .../loro-websocket/src/client/index.test.ts | 51 ++++++++++++++++++- packages/loro-websocket/src/client/index.ts | 24 ++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/loro-websocket/src/client/index.test.ts b/packages/loro-websocket/src/client/index.test.ts index 17c8783..7afa08d 100644 --- a/packages/loro-websocket/src/client/index.test.ts +++ b/packages/loro-websocket/src/client/index.test.ts @@ -6,7 +6,7 @@ import { type JoinError, } from "loro-protocol"; import * as protocol from "loro-protocol"; -import { LoroWebsocketClient } from "./index"; +import { JoinFailedError, LoroWebsocketClient } from "./index"; class FakeWebSocket { static CONNECTING = 0; @@ -139,4 +139,53 @@ describe("LoroWebsocketClient", () => { expect(onError).toHaveBeenCalledTimes(1); }); + + it("rejects a join with a typed error carrying the code and appCode", async () => { + const client = new LoroWebsocketClient({ + url: "ws://test", + disablePing: true, + reconnect: { enabled: false }, + }); + + (client as any).connectedPromise?.catch(() => { }); + + const adaptor = { + crdtType: CrdtType.Loro, + setCtx: () => { }, + getVersion: () => new Uint8Array([0]), + handleJoinOk: async () => { }, + waitForReachingServerVersion: async () => { }, + destroy: () => { }, + } satisfies any; + + const joinError: JoinError = { + type: MessageType.JoinError, + code: JoinErrorCode.AppError, + message: "room was purged", + appCode: "room_purged", + crdt: adaptor.crdtType, + roomId: "room", + }; + + let rejected: unknown; + const pending = { + room: Promise.resolve({} as any), + resolve: () => { }, + reject: (e: Error) => { + rejected = e; + }, + adaptor, + roomId: "room", + } satisfies any; + + await (client as any).handleJoinError(joinError, pending, adaptor.crdtType + "room"); + + expect(rejected).toBeInstanceOf(JoinFailedError); + expect(rejected).toMatchObject({ + code: JoinErrorCode.AppError, + appCode: "room_purged", + roomId: "room", + crdt: CrdtType.Loro, + }); + }); }); diff --git a/packages/loro-websocket/src/client/index.ts b/packages/loro-websocket/src/client/index.ts index 47d7982..2d9f0f2 100644 --- a/packages/loro-websocket/src/client/index.ts +++ b/packages/loro-websocket/src/client/index.ts @@ -24,6 +24,28 @@ import type { CrdtDocAdaptor } from "loro-adaptors"; export * from "loro-adaptors"; +/** + * Rejection reason for a failed `join`. Carries the wire fields of the + * `JoinError` that caused it so callers can branch on `code` — and, for + * `app_error`, on the application-defined `appCode` — instead of parsing + * `message`. The message text is unchanged from earlier releases. + */ +export class JoinFailedError extends Error { + readonly crdt: CrdtType; + readonly roomId: string; + readonly code: JoinErrorCode; + readonly appCode?: string; + + constructor(msg: JoinError) { + super(`Join failed: ${msg.code} - ${msg.message}`); + this.name = "JoinFailedError"; + this.crdt = msg.crdt; + this.roomId = msg.roomId; + this.code = msg.code; + this.appCode = msg.appCode; + } +} + export type AuthProvider = () => Uint8Array | Promise; type AuthOption = Uint8Array | AuthProvider; @@ -894,7 +916,7 @@ export class LoroWebsocketClient { } // No retry possible, reject the promise - const err = new Error(`Join failed: ${msg.code} - ${msg.message}`); + const err = new JoinFailedError(msg); this.emitRoomStatus( pending.adaptor.crdtType + pending.roomId, RoomJoinStatus.Error,