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,