Skip to content
Open
38 changes: 36 additions & 2 deletions crates/bindings-typescript/src/sdk/db_connection_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ function getClientMessageVariantTag(name: string): number {
return tag;
}

// Browser websocket `onerror` handlers receive an `ErrorEvent`, which does
// not extend `Error`. Normalize before emitting so `onConnectError` and
// `onDisconnect` callbacks always receive the documented `Error` shape.
function toError(e: unknown): Error {
if (e instanceof Error) return e;
const message = (e as ErrorEvent | undefined)?.message || 'WebSocket error';
return new Error(message, { cause: e });
}

const CLIENT_MESSAGE_CALL_REDUCER_TAG =
getClientMessageVariantTag('CallReducer');
const CLIENT_MESSAGE_CALL_PROCEDURE_TAG =
Expand Down Expand Up @@ -173,6 +182,21 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
*/
isDisconnectRequested = false;

/**
* Whether the initial connection handshake completed, i.e. the
* `InitialConnection` message was received and `onConnect` was invoked.
* Used to route websocket errors on an established connection to the
* `disconnect` path instead of `connectError`.
*/
#everConnected = false;

/**
* The websocket error that ended an established connection, if any,
* normalized to `Error`. Passed to the `disconnect` emit so `onDisconnect`
* callbacks receive the documented `error?: Error` shape.
*/
#connectionError?: Error = undefined;

/**
* Whether the underlying websocket has entered `CLOSING` (2) or `CLOSED`
* (3). This becomes true even when the browser never delivered an
Expand Down Expand Up @@ -378,11 +402,20 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>

this.ws.onclose = () => {
this.isActive = false;
this.#emitter.emit('disconnect', this);
this.#emitter.emit('disconnect', this, this.#connectionError);
Comment thread
sephirith marked this conversation as resolved.
};
this.ws.onerror = (e: ErrorEvent) => {
this.isActive = false;
this.#emitter.emit('connectError', this, e);
if (this.#everConnected) {
// An error on an established connection is not a connect
// failure. Record it and close the socket so the `onclose` ->
// 'disconnect' path handles teardown, per the documented
// `onDisconnect` contract.
this.#connectionError = toError(e);
this.ws?.close();
return;
}
this.#emitter.emit('connectError', this, toError(e));
};
this.ws.onopen = this.#handleOnOpen.bind(this);
this.ws.onmessage = this.#handleOnMessage.bind(this);
Expand Down Expand Up @@ -909,6 +942,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
this.token = serverMessage.value.token;
}
this.#setConnectionId(serverMessage.value.connectionId);
this.#everConnected = true;
this.#emitter.emit('connect', this, this.identity, this.token);
break;
}
Expand Down
44 changes: 44 additions & 0 deletions crates/bindings-typescript/tests/db_connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,50 @@ describe('DbConnection', () => {
expect(client.isActive).toBe(false);
});

test('routes websocket error after connect to onDisconnect as an Error', async () => {
const onDisconnectPromise = new Deferred<void>();
const wsAdapter = new WebsocketTestAdapter();
let connectErrorCalled = false;
let disconnectError: Error | undefined;

const client = DbConnection.builder()
.withUri('ws://127.0.0.1:1234')
.withDatabaseName('db')
.withWSFn(wsAdapter.openWebSocket)
.onConnectError(() => {
connectErrorCalled = true;
})
.onDisconnect((_ctx, error) => {
disconnectError = error;
onDisconnectPromise.resolve();
})
.build();

await client['wsPromise'];
wsAdapter.acceptConnection();
wsAdapter.sendToClient(
ServerMessage.InitialConnection({
identity: anIdentity,
token: 'a-token',
connectionId: ConnectionId.random(),
})
);

// Browser-style ErrorEvent shape: not an instanceof Error.
wsAdapter.error({
type: 'error',
message: 'mid-stream failure',
} as unknown as Error);

await onDisconnectPromise.promise;

expect(connectErrorCalled).toBe(false);
expect(wsAdapter.closed).toBe(true);
expect(client.isActive).toBe(false);
expect(disconnectError).toBeInstanceOf(Error);
expect(disconnectError!.message).toBe('mid-stream failure');
});

test('call onConnect callback after getting an identity', async () => {
const onConnectPromise = new Deferred<void>();

Expand Down
Loading