@vex-chat/libvex 6.1.9 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/codecs.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  FileSQLSchema,
22
22
  InviteSchema,
23
23
  KeyBundleSchema,
24
+ PasskeySchema,
24
25
  PermissionSchema,
25
26
  ServerSchema,
26
27
  UserSchema,
@@ -156,6 +157,35 @@ export const WhoamiCodec = createCodec(
156
157
 
157
158
  export const OtkCountCodec = createCodec(z.object({ count: z.number() }));
158
159
 
160
+ // ── Passkey response codecs ────────────────────────────────────────────────
161
+
162
+ export const PasskeyCodec = createCodec(PasskeySchema);
163
+ export const PasskeyArrayCodec = createCodec(z.array(PasskeySchema));
164
+
165
+ /**
166
+ * The shape of `/user/:id/passkeys/register/begin` and
167
+ * `/auth/passkey/begin` responses. `options` is the WebAuthn JSON
168
+ * the host hands straight to `navigator.credentials.create()` /
169
+ * `.get()` (via `@simplewebauthn/browser`); we don't validate its
170
+ * inner shape because both ends of the wire (`@simplewebauthn/server`
171
+ * on spire, `@simplewebauthn/browser` on the host) already do.
172
+ */
173
+ export const PasskeyOptionsCodec = createCodec(
174
+ z.object({
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies by simplewebauthn version
176
+ options: z.unknown() as z.ZodType<any>,
177
+ requestID: z.string(),
178
+ }),
179
+ );
180
+
181
+ export const PasskeyAuthFinishResponseCodec = createCodec(
182
+ z.object({
183
+ passkeyID: z.string(),
184
+ token: z.string(),
185
+ user: UserSchema,
186
+ }),
187
+ );
188
+
159
189
  // ── Helper: decode axios response buffer ────────────────────────────────────
160
190
 
161
191
  /**
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  Message,
24
24
  Messages,
25
25
  Moderation,
26
+ Passkeys,
26
27
  PendingDeviceApprovalStatus,
27
28
  PendingDeviceRegistration,
28
29
  PendingDeviceRequest,
@@ -47,4 +48,4 @@ export type {
47
48
  UnsavedPreKey,
48
49
  } from "./types/index.js";
49
50
  // Re-export app-facing types
50
- export type { Invite } from "@vex-chat/types";
51
+ export type { Invite, Passkey } from "@vex-chat/types";
@@ -102,11 +102,69 @@ export class WebSocketAdapter implements WebSocketLike {
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * Forward `data` to the underlying socket if and only if it's
107
+ * OPEN. Throws `WebSocketNotOpenError` (a typed, named error)
108
+ * otherwise, so callers can distinguish a teardown race from a
109
+ * protocol error and either retry on the next socket or drop
110
+ * the frame.
111
+ *
112
+ * Without this guard a transient teardown surfaces as
113
+ * `DOMException("INVALID_STATE_ERR")` from the platform WebSocket
114
+ * — opaque, hard to catch by name, and surfaced by React Native
115
+ * as an unhandled promise rejection (red box / "frozen UI") any
116
+ * time a `void this.send(...)` callsite (ping / pong / queued
117
+ * notify reply) is in flight when the close event lands.
118
+ */
105
119
  send(data: Uint8Array) {
106
- this.ws.send(new Uint8Array(data));
120
+ if (this.ws.readyState !== 1) {
121
+ throw new WebSocketNotOpenError(this.ws.readyState);
122
+ }
123
+ try {
124
+ this.ws.send(new Uint8Array(data));
125
+ } catch (err: unknown) {
126
+ // Handles the TOCTOU between the readyState check above
127
+ // and the actual `ws.send` call. React Native's bridge
128
+ // can dispatch a `websocketMessage` and a
129
+ // `websocketClosed` back-to-back in the same JS turn:
130
+ // our message listener observes readyState=1 because
131
+ // the close event hasn't been processed yet, but the
132
+ // underlying WebSocket has already transitioned native-
133
+ // side and `send` throws INVALID_STATE_ERR.
134
+ if (
135
+ err instanceof Error &&
136
+ /invalid_state|INVALID_STATE_ERR/i.test(err.message)
137
+ ) {
138
+ throw new WebSocketNotOpenError(this.ws.readyState);
139
+ }
140
+ throw err;
141
+ }
107
142
  }
108
143
 
109
144
  terminate() {
110
145
  this.ws.close();
111
146
  }
112
147
  }
148
+
149
+ /**
150
+ * Thrown when `send()` is called on a socket that's not in the OPEN
151
+ * state. Surfaced as a named, recognisable type so callers can
152
+ * distinguish "transient teardown race" from a real protocol error
153
+ * and either drop the frame (pings) or wait for reconnect (real
154
+ * payloads).
155
+ *
156
+ * Replaces the bare `DOMException("INVALID_STATE_ERR")` that the
157
+ * underlying WebSocket throws — that one is opaque, gets reported
158
+ * by RN's dev console as a red unhandled rejection, and freezes the
159
+ * passkey/foreground/network-swap recovery flow because every code
160
+ * path that voids the resulting promise leaks the rejection.
161
+ */
162
+ export class WebSocketNotOpenError extends Error {
163
+ public readonly readyState: number;
164
+
165
+ constructor(readyState: number) {
166
+ super(`WebSocket is not open (readyState=${readyState.toString()})`);
167
+ this.name = "WebSocketNotOpenError";
168
+ this.readyState = readyState;
169
+ }
170
+ }