@noverachat/sdk-web 0.0.3

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.
@@ -0,0 +1,15 @@
1
+ [English](README.md) | [한국어](README.ko.md)
2
+
3
+ # `generated/` — 편집 금지
4
+
5
+ 이 폴더의 모든 파일은 [`packages/protocol`](../../../protocol/README.ko.md)의 와이어 프로토콜 스키마에서 **자동 생성**된다. 손으로 고치지 마라 — 다음 생성 때 덮어써진다.
6
+
7
+ - **`wire.ts`** — 와이어 위 프레임 타입(`Ws*` in/out) + 공유 문자열 enum(`MessageType`, `DeleteScope`, `FileKind`, `FileThumbnailStatus`). `src/types.ts`가 이걸 재노출하므로, SDK의 나머지는 평소처럼 `./types.js`에서 import한다.
8
+
9
+ ## 와이어 타입을 바꾸려면
10
+
11
+ 1. `packages/protocol/schema/wire.schema.json`(단일 출처) 편집.
12
+ 2. `pnpm gen` 실행 (레포 루트에서).
13
+ 3. `sdk-web`(이 파일)과 `sdk-dart`가 함께 재생성된다.
14
+
15
+ 전체 파이프라인·설계 결정·함정은 [`packages/protocol`](../../../protocol/README.ko.md) 참고. CI는 `pnpm gen:check`로 커밋된 생성물이 stale이면 실패시킨다.
@@ -0,0 +1,15 @@
1
+ [English](README.md) | [한국어](README.ko.md)
2
+
3
+ # `generated/` — do not edit
4
+
5
+ Everything in this folder is **auto-generated** from the wire-protocol schema in [`packages/protocol`](../../../protocol/README.md). Don't hand-edit these files — your changes will be overwritten on the next generate.
6
+
7
+ - **`wire.ts`** — the on-the-wire frame types (`Ws*` in/out) + shared string enums (`MessageType`, `DeleteScope`, `FileKind`, `FileThumbnailStatus`). `src/types.ts` re-exports them, so the rest of the SDK imports from `./types.js` as usual.
8
+
9
+ ## To change a wire type
10
+
11
+ 1. Edit `packages/protocol/schema/wire.schema.json` (the single source of truth).
12
+ 2. Run `pnpm gen` (from the repo root).
13
+ 3. Both `sdk-web` (this file) and `sdk-dart` regenerate together.
14
+
15
+ See [`packages/protocol`](../../../protocol/README.md) for the full pipeline, design decisions, and gotchas. CI runs `pnpm gen:check` to fail if these committed files are stale.
@@ -0,0 +1,224 @@
1
+ /* GENERATED — do not edit. Source: packages/protocol/schema/. Run `pnpm gen` (repo root). */
2
+ export type WsInbound =
3
+ | WsConnected
4
+ | WsAck
5
+ | WsChatReceive
6
+ | WsMessageUpdated
7
+ | WsMessageDeleted
8
+ | WsMessagesCleared
9
+ | WsReactionAdded
10
+ | WsReactionRemoved
11
+ | WsSyncTick
12
+ | WsTypingBroadcast
13
+ | WsRoomEvent
14
+ | WsAnnouncementEvent
15
+ | WsPresence
16
+ | WsPong
17
+ | WsError
18
+ | WsCloseNotice;
19
+ export type MessageType = "TEXT" | "FILE" | "EMOTICON" | "ADMIN" | "SYSTEM";
20
+ export type FileKind = "image" | "video" | "file";
21
+ export type FileThumbnailStatus = "pending" | "ready" | "failed" | "skipped";
22
+ export type DeleteScope = "MY" | "ALL";
23
+ export type WsOutbound = WsChatSend | WsReadWatermark | WsTypingSend | WsPing;
24
+
25
+ export interface WsConnected {
26
+ type: "connected";
27
+ session_id: string;
28
+ server_ts: number;
29
+ protocol_version: string;
30
+ gap_fill_url: string;
31
+ }
32
+ export interface WsAck {
33
+ type: "ack";
34
+ temp_id: string | null;
35
+ message_id: string;
36
+ room_id: string;
37
+ server_ts: number;
38
+ }
39
+ export interface WsChatReceive {
40
+ type: "chat_receive";
41
+ room_id: string;
42
+ /**
43
+ * Snowflake id as string
44
+ */
45
+ message_id: string;
46
+ sender_id: string | null;
47
+ message_type: MessageType;
48
+ custom_type?: string | null;
49
+ content?: string | null;
50
+ data?: {
51
+ [k: string]: unknown;
52
+ } | null;
53
+ meta?: {
54
+ [k: string]: unknown;
55
+ } | null;
56
+ file_id?: string | null;
57
+ file?: FileInlineWire | null;
58
+ files?: FileInlineWire[] | null;
59
+ reply_to_id?: string | null;
60
+ /**
61
+ * unix ms
62
+ */
63
+ created_at: number;
64
+ /**
65
+ * unix ms
66
+ */
67
+ server_ts: number;
68
+ /**
69
+ * server-echoed on self-echo only
70
+ */
71
+ temp_id?: string | null;
72
+ }
73
+ export interface FileInlineWire {
74
+ id: string;
75
+ kind: FileKind;
76
+ mime: string;
77
+ size: number;
78
+ name?: string | null;
79
+ width?: number | null;
80
+ height?: number | null;
81
+ duration_sec?: number | null;
82
+ thumbnail_status: FileThumbnailStatus;
83
+ forward_blocked?: boolean;
84
+ }
85
+ export interface WsMessageUpdated {
86
+ type: "message_updated";
87
+ room_id: string;
88
+ message_id: string;
89
+ content?: string | null;
90
+ data?: {
91
+ [k: string]: unknown;
92
+ } | null;
93
+ meta?: {
94
+ [k: string]: unknown;
95
+ } | null;
96
+ updated_at: number;
97
+ }
98
+ export interface WsMessageDeleted {
99
+ type: "message_deleted";
100
+ room_id: string;
101
+ message_id: string;
102
+ scope: DeleteScope;
103
+ deleted_by_user_id?: string | null;
104
+ deleted_at: number;
105
+ }
106
+ export interface WsMessagesCleared {
107
+ type: "messages_cleared";
108
+ room_id: string;
109
+ up_to_message_id: string | null;
110
+ cleared_by_user_id: string | null;
111
+ cleared_count: number;
112
+ cleared_at: number;
113
+ }
114
+ export interface WsReactionAdded {
115
+ type: "reaction_added";
116
+ room_id: string;
117
+ message_id: string;
118
+ reaction_key: string;
119
+ user_id: string;
120
+ updated_at: number;
121
+ }
122
+ export interface WsReactionRemoved {
123
+ type: "reaction_removed";
124
+ room_id: string;
125
+ message_id: string;
126
+ reaction_key: string;
127
+ user_id: string;
128
+ updated_at: number;
129
+ }
130
+ export interface WsSyncTick {
131
+ type: "sync_tick";
132
+ room_id: string;
133
+ read_states: {
134
+ [k: string]: string;
135
+ };
136
+ }
137
+ export interface WsTypingBroadcast {
138
+ type: "typing";
139
+ room_id: string;
140
+ user_id: string;
141
+ is_typing: boolean;
142
+ }
143
+ export interface WsRoomEvent {
144
+ type: "room_event";
145
+ event: string;
146
+ room_id: string;
147
+ target_user_id: string;
148
+ by_user_id: string | null;
149
+ server_ts: number;
150
+ }
151
+ export interface WsAnnouncementEvent {
152
+ type: "announcement_event";
153
+ event: "CREATED" | "UPDATED" | "DELETED";
154
+ room_id: string;
155
+ announcement: AnnouncementWire | null;
156
+ announcement_id: number;
157
+ by: string;
158
+ at: number;
159
+ }
160
+ export interface AnnouncementWire {
161
+ id: number;
162
+ room_id: string;
163
+ content: string;
164
+ created_by: string;
165
+ created_at: string;
166
+ updated_at: string;
167
+ }
168
+ export interface WsPresence {
169
+ type: "presence";
170
+ user_id: string;
171
+ online: boolean;
172
+ at: number;
173
+ }
174
+ export interface WsPong {
175
+ type: "pong";
176
+ server_ts: number;
177
+ client_ts?: number | null;
178
+ }
179
+ export interface WsError {
180
+ type: "error";
181
+ code: string;
182
+ message: string;
183
+ details?: {
184
+ [k: string]: unknown;
185
+ } | null;
186
+ }
187
+ export interface WsCloseNotice {
188
+ type: "close_notice";
189
+ reason: string;
190
+ retry_after_ms?: number;
191
+ }
192
+ export interface WsChatSend {
193
+ type: "chat_send";
194
+ room_id: string;
195
+ content?: string | null;
196
+ message_type: "TEXT" | "FILE" | "EMOTICON";
197
+ custom_type?: string | null;
198
+ data?: {
199
+ [k: string]: unknown;
200
+ } | null;
201
+ meta?: {
202
+ [k: string]: unknown;
203
+ } | null;
204
+ file_id?: string | null;
205
+ reply_to_id?: string | null;
206
+ forward_of_message_id?: string | null;
207
+ temp_id?: string | null;
208
+ client_ts: number;
209
+ }
210
+ export interface WsReadWatermark {
211
+ type: "read_watermark";
212
+ room_id: string;
213
+ last_read_message_id: string;
214
+ }
215
+ export interface WsTypingSend {
216
+ type: "typing";
217
+ room_id: string;
218
+ is_typing: boolean;
219
+ }
220
+ export interface WsPing {
221
+ type: "ping";
222
+ client_ts: number;
223
+ }
224
+
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ export { NoveraChat } from "./client.js";
2
+ export { Room, type RoomEvents } from "./features/room.js";
3
+ export {
4
+ ErrorCode,
5
+ NoveraChatError,
6
+ type ErrorCodeValue,
7
+ } from "./errors.js";
8
+ export type {
9
+ AcceptInviteResult,
10
+ Announcement,
11
+ AppPolicy,
12
+ Block,
13
+ ClientOptions,
14
+ DeleteScope,
15
+ InviteToken,
16
+ MessageOut,
17
+ MessageType,
18
+ ReactionEntry,
19
+ RoomUnread,
20
+ SendParams,
21
+ SenderMini,
22
+ UnreadRoomItem,
23
+ UnreadSummary,
24
+ WsAnnouncementEvent,
25
+ WsInbound,
26
+ WsOutbound,
27
+ } from "./types.js";
@@ -0,0 +1,42 @@
1
+ /** Tiny typed EventEmitter. No deps. */
2
+
3
+ type Handler<T> = (payload: T) => void;
4
+
5
+ // NOTE: constraint is `object` rather than `Record<string, unknown>` because
6
+ // `interface RoomEvents { ... }` (no explicit index signature) does not
7
+ // satisfy the latter under modern TS strictness — interfaces aren't
8
+ // considered assignable to index-signature types. `object` is enough since
9
+ // we only key into Events via its declared keys.
10
+ export class EventBus<Events extends object> {
11
+ private handlers: { [K in keyof Events]?: Set<Handler<Events[K]>> } = {};
12
+
13
+ on<K extends keyof Events>(event: K, fn: Handler<Events[K]>): () => void {
14
+ let set = this.handlers[event];
15
+ if (!set) {
16
+ set = new Set();
17
+ this.handlers[event] = set;
18
+ }
19
+ set.add(fn);
20
+ return () => this.off(event, fn);
21
+ }
22
+
23
+ off<K extends keyof Events>(event: K, fn: Handler<Events[K]>): void {
24
+ this.handlers[event]?.delete(fn);
25
+ }
26
+
27
+ emit<K extends keyof Events>(event: K, payload: Events[K]): void {
28
+ const set = this.handlers[event];
29
+ if (!set) return;
30
+ for (const h of set) {
31
+ try {
32
+ h(payload);
33
+ } catch {
34
+ // user handler error must not break the bus
35
+ }
36
+ }
37
+ }
38
+
39
+ removeAll(): void {
40
+ this.handlers = {};
41
+ }
42
+ }
@@ -0,0 +1,10 @@
1
+ /** Lightweight client-side UUID-ish for optimistic UI keys. */
2
+
3
+ export function tempId(): string {
4
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
5
+ return `tmp_${crypto.randomUUID()}`;
6
+ }
7
+ // fallback — collision-resistant enough for UI tracking
8
+ const bytes = Array.from({ length: 16 }, () => Math.floor(Math.random() * 256));
9
+ return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
10
+ }
@@ -0,0 +1,81 @@
1
+ import { NoveraChatError } from "../errors.js";
2
+
3
+ export interface HttpOptions {
4
+ baseUrl: string;
5
+ appId: string;
6
+ tokenProvider: () => Promise<string>;
7
+ fetch?: typeof fetch;
8
+ // Optional structured logger — same signature as the WS transport's
9
+ // so the demo / consumers can attach a single sink for both pipes.
10
+ // Frame/request-level lines come in at ``debug``; non-2xx responses
11
+ // and exceptions at ``warn`` / ``error``.
12
+ logger?: (
13
+ lvl: "debug" | "info" | "warn" | "error",
14
+ msg: string,
15
+ ctx?: unknown,
16
+ ) => void;
17
+ }
18
+
19
+ export class HttpClient {
20
+ constructor(private opts: HttpOptions) {}
21
+
22
+ async request<T>(
23
+ method: string,
24
+ path: string,
25
+ body?: unknown,
26
+ query?: Record<string, string | number | undefined>,
27
+ ): Promise<T> {
28
+ const token = await this.opts.tokenProvider();
29
+ const url = this.buildUrl(path, query);
30
+ // Build RequestInit conditionally — under `exactOptionalPropertyTypes`,
31
+ // assigning `body: undefined` is not the same as omitting `body`.
32
+ const init: RequestInit = {
33
+ method,
34
+ headers: {
35
+ "Authorization": `Bearer ${token}`,
36
+ "X-Noverachat-App-Id": this.opts.appId,
37
+ ...(body != null ? { "Content-Type": "application/json" } : {}),
38
+ },
39
+ };
40
+ if (body != null) init.body = JSON.stringify(body);
41
+ // Native browser `fetch` is bound to `window`/`globalThis` — calling it
42
+ // as `this._fetch(...)` (method-style on HttpClient) makes `this` the
43
+ // HttpClient and triggers "Failed to execute 'fetch' on 'Window':
44
+ // Illegal invocation". Detach into a local variable so the call is
45
+ // plain function invocation.
46
+ const fetchFn = this.opts.fetch ?? globalThis.fetch;
47
+ // Per-request trace. Body is logged as-is (not redacted) — the
48
+ // SDK doesn't see secrets at this layer (the bearer token is in
49
+ // the Authorization header which we don't echo).
50
+ this.opts.logger?.("debug", `rest_req ${method} ${path}`,
51
+ body != null ? { body, query } : { query });
52
+ const startedAt = Date.now();
53
+ const res = await fetchFn(url, init);
54
+ const elapsedMs = Date.now() - startedAt;
55
+ if (res.status === 204) {
56
+ this.opts.logger?.("debug",
57
+ `rest_res ${method} ${path} 204 ${elapsedMs}ms`);
58
+ return undefined as unknown as T;
59
+ }
60
+ const text = await res.text();
61
+ const parsed = text ? (JSON.parse(text) as unknown) : null;
62
+ this.opts.logger?.("debug",
63
+ `rest_res ${method} ${path} ${res.status} ${elapsedMs}ms`,
64
+ parsed);
65
+ if (!res.ok) {
66
+ throw NoveraChatError.fromResponse(res.status, parsed);
67
+ }
68
+ return parsed as T;
69
+ }
70
+
71
+ private buildUrl(path: string, query?: Record<string, string | number | undefined>): string {
72
+ const base = this.opts.baseUrl.replace(/\/+$/, "");
73
+ const url = new URL(base + path);
74
+ if (query) {
75
+ for (const [k, v] of Object.entries(query)) {
76
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
77
+ }
78
+ }
79
+ return url.toString();
80
+ }
81
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Reconnecting WebSocket — state machine per docs/ws_protocol.md §6.
3
+ *
4
+ * IDLE → CONNECTING → OPEN → RECONNECTING → CONNECTING …
5
+ *
6
+ * - Exponential backoff with jitter (500ms → 30s cap)
7
+ * - Refuses to reconnect on 1008/4001/4002 (policy / app-inactive / user-banned)
8
+ * - Emits typed events via callbacks
9
+ */
10
+
11
+ import type { WsInbound, WsOutbound } from "../types.js";
12
+
13
+ export type WsState = "idle" | "connecting" | "open" | "reconnecting" | "closed";
14
+
15
+ export interface ReconnectingWsOptions {
16
+ urlBuilder: () => Promise<string>;
17
+ onMessage: (msg: WsInbound) => void;
18
+ onStateChange?: (s: WsState) => void;
19
+ onOpen?: () => void;
20
+ pingIntervalMs: number;
21
+ maxReconnectDelayMs: number;
22
+ autoReconnect: boolean;
23
+ WebSocketImpl?: typeof WebSocket;
24
+ logger?: (lvl: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
25
+ }
26
+
27
+ // Codes on which we must NOT try to reconnect
28
+ const NO_RECONNECT = new Set<number>([1008, 4001, 4002, 4003]);
29
+
30
+ export class ReconnectingWs {
31
+ private ws: WebSocket | null = null;
32
+ private state: WsState = "idle";
33
+ private attempt = 0;
34
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
35
+ private explicitClose = false;
36
+ // Outbound buffer used while the socket is transiently down (reconnect
37
+ // window between a server-side close and the next successful open). FIFO.
38
+ // Capped to avoid an unbounded queue if the server is hard-down — chat_send
39
+ // bursts past the cap are dropped (caller sees `send -> false` the usual
40
+ // way). Pings and typing frames are NOT queued: pings are stateless
41
+ // heartbeats and a stale typing event is worse than no event.
42
+ private outboundQueue: WsOutbound[] = [];
43
+ private static readonly OUTBOUND_QUEUE_CAP = 200;
44
+
45
+ constructor(private opts: ReconnectingWsOptions) {}
46
+
47
+ get currentState(): WsState {
48
+ return this.state;
49
+ }
50
+
51
+ async connect(): Promise<void> {
52
+ this.explicitClose = false;
53
+ await this.openOnce();
54
+ }
55
+
56
+ close(): void {
57
+ this.explicitClose = true;
58
+ this.setState("closed");
59
+ if (this.pingTimer) clearInterval(this.pingTimer);
60
+ this.pingTimer = null;
61
+ this.outboundQueue = [];
62
+ this.ws?.close(1000, "client close");
63
+ this.ws = null;
64
+ }
65
+
66
+ send(msg: WsOutbound): boolean {
67
+ if (this.ws && this.ws.readyState === 1 /* OPEN */) {
68
+ this.ws.send(JSON.stringify(msg));
69
+ // Frame-level trace. Pings are tagged ``debug`` so they don't drown
70
+ // the log at the default verbosity — other frames are ``debug`` too
71
+ // but they're rare enough per second that consumers (e.g. the demo
72
+ // log panel) usually want to see them with a toggle.
73
+ this.opts.logger?.("debug", `ws_send ${msg.type}`, msg);
74
+ return true;
75
+ }
76
+ // Socket not open right now. For frame types where late-delivery is
77
+ // worse than non-delivery, fail fast and let the caller decide. For
78
+ // chat_send and read_watermark we buffer and replay on next open so
79
+ // brief reconnect windows (sub-second) don't surface as "send failed"
80
+ // noise in the UI.
81
+ if (msg.type === "ping" || msg.type === "typing") return false;
82
+ if (this.explicitClose || this.state === "closed") return false;
83
+ if (this.outboundQueue.length >= ReconnectingWs.OUTBOUND_QUEUE_CAP) {
84
+ this.opts.logger?.("warn", "ws_outbound_queue_full",
85
+ { cap: ReconnectingWs.OUTBOUND_QUEUE_CAP, dropping: msg.type });
86
+ return false;
87
+ }
88
+ this.outboundQueue.push(msg);
89
+ this.opts.logger?.("debug", `ws_send_queued ${msg.type}`,
90
+ { queueLen: this.outboundQueue.length });
91
+ return true;
92
+ }
93
+
94
+ private async openOnce(): Promise<void> {
95
+ this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
96
+ const WSImpl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as typeof WebSocket);
97
+ if (!WSImpl) throw new Error("No WebSocket implementation available");
98
+
99
+ let url: string;
100
+ try {
101
+ url = await this.opts.urlBuilder();
102
+ } catch (err) {
103
+ this.opts.logger?.("error", "url_builder_failed", err);
104
+ this.scheduleReconnect();
105
+ return;
106
+ }
107
+
108
+ const ws = new WSImpl(url);
109
+ this.ws = ws;
110
+
111
+ ws.onopen = () => {
112
+ this.attempt = 0;
113
+ this.setState("open");
114
+ this.startPings();
115
+ // Drain anything that piled up while the socket was down. FIFO so
116
+ // chat_send temp_id ordering matches the user's typing order, which
117
+ // self-echo dedup in client.ts relies on.
118
+ if (this.outboundQueue.length > 0) {
119
+ const drained = this.outboundQueue;
120
+ this.outboundQueue = [];
121
+ this.opts.logger?.("info", "ws_outbound_drained", { count: drained.length });
122
+ for (const m of drained) {
123
+ try {
124
+ ws.send(JSON.stringify(m));
125
+ } catch (err) {
126
+ this.opts.logger?.("warn", "ws_outbound_drain_failed", err);
127
+ }
128
+ }
129
+ }
130
+ this.opts.onOpen?.();
131
+ };
132
+
133
+ ws.onmessage = (ev: MessageEvent) => {
134
+ try {
135
+ const data = typeof ev.data === "string" ? ev.data : ev.data.toString();
136
+ const parsed = JSON.parse(data) as WsInbound;
137
+ // Frame-level trace of every inbound frame BEFORE dispatch so the
138
+ // log line lands even if downstream handlers throw.
139
+ this.opts.logger?.("debug",
140
+ `ws_recv ${(parsed as { type?: string }).type ?? "?"}`,
141
+ parsed);
142
+ this.opts.onMessage(parsed);
143
+ } catch (err) {
144
+ this.opts.logger?.("warn", "ws_parse_failed", err);
145
+ }
146
+ };
147
+
148
+ ws.onerror = (ev) => {
149
+ this.opts.logger?.("warn", "ws_error", ev);
150
+ };
151
+
152
+ ws.onclose = (ev: CloseEvent) => {
153
+ this.stopPings();
154
+ this.ws = null;
155
+ if (this.explicitClose) {
156
+ this.setState("closed");
157
+ return;
158
+ }
159
+ if (NO_RECONNECT.has(ev.code)) {
160
+ this.opts.logger?.("warn", "ws_fatal_close", { code: ev.code, reason: ev.reason });
161
+ this.setState("closed");
162
+ return;
163
+ }
164
+ if (this.opts.autoReconnect) this.scheduleReconnect();
165
+ else this.setState("closed");
166
+ };
167
+ }
168
+
169
+ private scheduleReconnect(): void {
170
+ this.attempt += 1;
171
+ const base = 500;
172
+ const cap = this.opts.maxReconnectDelayMs;
173
+ const exp = Math.min(cap, base * 2 ** (this.attempt - 1));
174
+ const jitter = Math.floor(Math.random() * base);
175
+ const delay = exp + jitter;
176
+ this.opts.logger?.("info", "ws_reconnect_scheduled", { attempt: this.attempt, delay });
177
+ this.setState("reconnecting");
178
+ setTimeout(() => {
179
+ void this.openOnce();
180
+ }, delay);
181
+ }
182
+
183
+ private startPings(): void {
184
+ this.stopPings();
185
+ this.pingTimer = setInterval(() => {
186
+ this.send({ type: "ping", client_ts: Date.now() });
187
+ }, this.opts.pingIntervalMs);
188
+ }
189
+
190
+ private stopPings(): void {
191
+ if (this.pingTimer) clearInterval(this.pingTimer);
192
+ this.pingTimer = null;
193
+ }
194
+
195
+ private setState(s: WsState): void {
196
+ if (s === this.state) return;
197
+ this.state = s;
198
+ this.opts.onStateChange?.(s);
199
+ }
200
+ }