openclaw-app 1.0.0

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/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @openclaw/mobile
2
+
3
+ OpenClaw channel plugin for the Mobile app. Routes messages through a Cloudflare Worker relay.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Mobile App ←WS→ [CF Worker / Durable Object] ←WS→ [OpenClaw Plugin] → Gateway Pipeline
9
+ ```
10
+
11
+ 1. Plugin registers an `openclaw-mobile` channel with the Gateway
12
+ 2. Gateway manages the channel lifecycle (start, stop, health monitoring)
13
+ 3. The plugin connects outbound to the CF Worker relay via WebSocket
14
+ 4. User messages from the mobile app flow through the relay into the Gateway's inbound pipeline
15
+ 5. AI replies go through `outbound.sendText` — Gateway automatically filters security markers and thinking blocks
16
+ 6. Filtered replies are forwarded to the mobile app via the relay
17
+ 7. A 30-second ping keepalive prevents the Cloudflare Durable Object from hibernating and dropping the connection
18
+
19
+ ## Prerequisites
20
+
21
+ - OpenClaw Gateway installed and running (`npm install -g openclaw@latest`)
22
+ - A deployed Cloudflare Worker relay (see `../relay/`)
23
+ - Node.js 22+
24
+
25
+ ## Install
26
+
27
+ From npm (when published):
28
+
29
+ ```bash
30
+ openclaw plugins install @openclaw/mobile
31
+ ```
32
+
33
+ For local development:
34
+
35
+ ```bash
36
+ openclaw plugins install -l ./plugin
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### 1. Deploy the relay
42
+
43
+ ```bash
44
+ cd relay
45
+ npm install
46
+ npx wrangler deploy
47
+ ```
48
+
49
+ Note the deployed URL (e.g. `wss://openclaw-relay.your-name.workers.dev`).
50
+
51
+ If you want relay authentication, set a secret:
52
+
53
+ ```bash
54
+ npx wrangler secret put RELAY_TOKEN
55
+ ```
56
+
57
+ ### 2. Install the plugin
58
+
59
+ ```bash
60
+ openclaw plugins install -l ./plugin
61
+ ```
62
+
63
+ ### 3. Configure
64
+
65
+ Add to `~/.openclaw/openclaw.json`:
66
+
67
+ ```json
68
+ {
69
+ "channels": {
70
+ "openclaw-mobile": {
71
+ "accounts": {
72
+ "default": {
73
+ "enabled": true,
74
+ "relayUrl": "wss://openclaw-relay.your-name.workers.dev",
75
+ "relayToken": "your-secret-token",
76
+ "roomId": "default"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### 4. Restart the Gateway
85
+
86
+ ```bash
87
+ openclaw gateway stop
88
+ openclaw gateway --port 18789
89
+ ```
90
+
91
+ ### 5. Verify
92
+
93
+ Open the Control UI at http://127.0.0.1:18789/ and navigate to the **OpenClaw Mobile** channel page. You should see:
94
+
95
+ - **Running**: Yes
96
+ - **Configured**: Yes
97
+ - **Connected**: Yes
98
+
99
+ You can also check logs:
100
+
101
+ ```bash
102
+ tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
103
+ ```
104
+
105
+ ## Configuration
106
+
107
+ All config lives under `channels.openclaw-mobile.accounts.<accountId>` in `~/.openclaw/openclaw.json`.
108
+
109
+ | Field | Type | Required | Default | Description |
110
+ |-------|------|----------|---------|-------------|
111
+ | `enabled` | boolean | No | `true` | Enable/disable this account |
112
+ | `relayUrl` | string | **Yes** | — | CF Worker relay WebSocket URL |
113
+ | `relayToken` | string | No | `""` | Shared secret for relay authentication (must match the Worker's `RELAY_TOKEN` secret) |
114
+ | `roomId` | string | No | `"default"` | Room ID for relay routing (isolates conversations) |
115
+
116
+ You can also edit these fields directly in the Control UI config form.
117
+
118
+ ## Control UI
119
+
120
+ The plugin integrates with the OpenClaw Control UI:
121
+
122
+ - **Status panel** — shows Running, Configured, Connected, Last inbound, and error details
123
+ - **Config form** — editable fields for Relay URL, Relay Token, Room ID, and an Enable/Disable toggle
124
+ - **Enable/Disable** — the toggle in the UI writes `enabled: true/false` to the config and restarts the channel
125
+
126
+ ## How the relay works
127
+
128
+ The relay is a Cloudflare Worker with a Durable Object (`RelayRoom`):
129
+
130
+ - Each "room" is a named DO instance that bridges two WebSocket roles: `plugin` (the Gateway) and `app` (the mobile client)
131
+ - Messages from one role are forwarded to all peers of the opposite role
132
+ - The DO uses `setWebSocketAutoResponse("ping", "pong")` for keepalive without waking from hibernation
133
+ - The plugin sends a `ping` every 30 seconds to prevent idle disconnection
134
+ - Optional `RELAY_TOKEN` secret gates access to the relay
135
+
136
+ ## Message Protocol
137
+
138
+ ### App → Plugin (via relay)
139
+
140
+ ```json
141
+ {
142
+ "type": "message",
143
+ "content": "Hello",
144
+ "sessionKey": "relay-session",
145
+ "senderId": "mobile-user",
146
+ "senderName": "Mobile User"
147
+ }
148
+ ```
149
+
150
+ - `sessionKey` — the app's local session identifier; the plugin echoes it back in replies so the app can match them
151
+ - `senderId` / `senderName` — optional; defaults to `"mobile-user"` / `"Mobile User"`
152
+
153
+ ### Plugin → App (via relay)
154
+
155
+ ```json
156
+ {
157
+ "type": "message",
158
+ "role": "assistant",
159
+ "content": "AI reply text",
160
+ "sessionKey": "relay-session"
161
+ }
162
+ ```
163
+
164
+ The plugin echoes back the exact `sessionKey` it received from the app, so the Flutter client can route the reply to the correct chat session.
165
+
166
+ ## Troubleshooting
167
+
168
+ | Problem | Cause | Fix |
169
+ |---------|-------|-----|
170
+ | "Channel config schema unavailable" in Control UI | Gateway loaded before plugin was installed | Restart the Gateway: `openclaw gateway stop && openclaw gateway` |
171
+ | Running: No | `startAccount` returned early (old plugin version) | Update the plugin and restart Gateway |
172
+ | Connected: No | Relay URL wrong or Worker not deployed | Check `relayUrl` in config; verify Worker is live with `curl https://your-relay.workers.dev/health` |
173
+ | Relay keeps disconnecting | Ping keepalive not working or network issue | Check logs for "WebSocket error"; ensure relay Worker is deployed with Durable Objects enabled |
174
+ | "relayUrl not configured" in logs | Missing `relayUrl` in account config | Add `relayUrl` under `channels.openclaw-mobile.accounts.default` |
175
+ | Enable toggle in UI doesn't match JSON | Missing `setAccountEnabled` adapter (old plugin version) | Update the plugin and restart Gateway |
176
+ | `relayToken` mismatch | Token in config doesn't match Worker secret | Ensure `relayToken` matches the `RELAY_TOKEN` secret set on the Worker |
177
+ | App stuck loading, no reply shown | sessionKey mismatch between app and plugin | Ensure app sends `sessionKey` in message payload and plugin version ≥ current |
178
+
179
+ ### Useful commands
180
+
181
+ ```bash
182
+ # Check plugin is loaded
183
+ openclaw plugins list
184
+
185
+ # Check channel status
186
+ openclaw channels status --probe
187
+
188
+ # Live logs
189
+ tail -f ~/.openclaw/logs/gateway.log | grep openclaw-mobile
190
+
191
+ # Verify relay is reachable
192
+ curl https://openclaw-relay.your-name.workers.dev/health
193
+ ```
package/index.ts ADDED
@@ -0,0 +1,866 @@
1
+ /**
2
+ * OpenClaw Mobile Channel Plugin
3
+ *
4
+ * Registers "openclaw-mobile" channel that bridges Gateway <-> CF Worker relay <-> Mobile App.
5
+ * Plugin runs inside the Gateway process, connects outbound to the relay.
6
+ * No external dependencies — uses Node.js built-in WebSocket.
7
+ *
8
+ * Config (in ~/.openclaw/openclaw.json):
9
+ * {
10
+ * "channels": {
11
+ * "openclaw-mobile": {
12
+ * "accounts": {
13
+ * "default": {
14
+ * "enabled": true,
15
+ * "relayUrl": "wss://openclaw-relay.xxx.workers.dev",
16
+ * "relayToken": "your-secret",
17
+ * "roomId": "default"
18
+ * }
19
+ * }
20
+ * }
21
+ * }
22
+ * }
23
+ */
24
+
25
+ // ── Plugin runtime (set during register) ────────────────────────────────────
26
+
27
+ let pluginRuntime: any = null;
28
+
29
+ // ── E2E Encryption (X25519 ECDH + AES-256-GCM) ──────────────────────────────
30
+ // Uses Web Crypto API. Node 18+ uses standalone "X25519" algorithm name,
31
+ // while browsers use { name: "ECDH", namedCurve: "X25519" }.
32
+
33
+ // Runtime detection — resolved once on first use
34
+ let _x25519Algo: { gen: any; imp: any; derive: string } | null = null;
35
+ async function getX25519Algo() {
36
+ if (_x25519Algo) return _x25519Algo;
37
+ try {
38
+ await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "X25519" } as any, true, ["deriveKey"]);
39
+ _x25519Algo = { gen: { name: "ECDH", namedCurve: "X25519" }, imp: { name: "ECDH", namedCurve: "X25519" }, derive: "ECDH" };
40
+ } catch {
41
+ _x25519Algo = { gen: { name: "X25519" }, imp: { name: "X25519" }, derive: "X25519" };
42
+ }
43
+ return _x25519Algo;
44
+ }
45
+
46
+ interface E2EState {
47
+ localKeyPair: CryptoKeyPair | null;
48
+ sharedKey: CryptoKey | null;
49
+ ready: boolean;
50
+ }
51
+
52
+ function makeE2EState(): E2EState {
53
+ return { localKeyPair: null, sharedKey: null, ready: false };
54
+ }
55
+
56
+ async function e2eInit(state: E2EState): Promise<string> {
57
+ const algo = await getX25519Algo();
58
+ state.localKeyPair = await crypto.subtle.generateKey(
59
+ algo.gen,
60
+ true,
61
+ ["deriveKey"]
62
+ );
63
+ const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
64
+ const pubKeyB64 = bufToBase64Url(pubKeyRaw);
65
+ return JSON.stringify({ type: "handshake", pubkey: pubKeyB64 });
66
+ }
67
+
68
+ async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
69
+ if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
70
+ const algo = await getX25519Algo();
71
+
72
+ const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
73
+ const peerPublicKey = await crypto.subtle.importKey(
74
+ "raw",
75
+ peerPubKeyBytes.buffer as ArrayBuffer,
76
+ algo.imp,
77
+ false,
78
+ []
79
+ );
80
+
81
+ const ecdhRawKey = await crypto.subtle.deriveKey(
82
+ { name: algo.derive, public: peerPublicKey },
83
+ state.localKeyPair.privateKey,
84
+ { name: "AES-GCM", length: 256 },
85
+ true,
86
+ ["encrypt", "decrypt"]
87
+ );
88
+ const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
89
+
90
+ // HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
91
+ const hkdfKey = await crypto.subtle.importKey(
92
+ "raw", ecdhRawBytes as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]
93
+ );
94
+ state.sharedKey = await crypto.subtle.deriveKey(
95
+ {
96
+ name: "HKDF",
97
+ hash: "SHA-256",
98
+ salt: new Uint8Array(0),
99
+ info: new TextEncoder().encode("openclaw-e2e-v1"),
100
+ },
101
+ hkdfKey,
102
+ { name: "AES-GCM", length: 256 },
103
+ false,
104
+ ["encrypt", "decrypt"]
105
+ );
106
+
107
+ state.ready = true;
108
+ }
109
+
110
+ async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
111
+ if (!state.sharedKey) throw new Error("[E2E] Not ready");
112
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
113
+ const ct = await crypto.subtle.encrypt(
114
+ { name: "AES-GCM", iv: nonce },
115
+ state.sharedKey,
116
+ new TextEncoder().encode(plaintext)
117
+ );
118
+ return JSON.stringify({
119
+ type: "encrypted",
120
+ nonce: bufToBase64Url(nonce),
121
+ ct: bufToBase64Url(ct),
122
+ });
123
+ }
124
+
125
+ async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Promise<string> {
126
+ if (!state.sharedKey) throw new Error("[E2E] Not ready");
127
+ const nonce = base64UrlToBuf(nonceB64);
128
+ const ct = base64UrlToBuf(ctB64);
129
+ const plain = await crypto.subtle.decrypt(
130
+ { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer },
131
+ state.sharedKey,
132
+ ct.buffer as ArrayBuffer
133
+ );
134
+ return new TextDecoder().decode(plain);
135
+ }
136
+
137
+ function bufToBase64Url(buf: ArrayBuffer | Uint8Array): string {
138
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
139
+ let binary = "";
140
+ for (const b of bytes) binary += String.fromCharCode(b);
141
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
142
+ }
143
+
144
+ function base64UrlToBuf(b64: string): Uint8Array {
145
+ const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
146
+ const pad = (4 - padded.length % 4) % 4;
147
+ const binary = atob(padded + "=".repeat(pad));
148
+ const bytes = new Uint8Array(binary.length);
149
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
150
+ return bytes;
151
+ }
152
+
153
+ // ── Relay state (per account) ────────────────────────────────────────────────
154
+
155
+ interface RelayState {
156
+ ws: WebSocket | null;
157
+ reconnectTimer: ReturnType<typeof setTimeout> | null;
158
+ pingTimer: ReturnType<typeof setInterval> | null;
159
+ statusSink: ((patch: Record<string, unknown>) => void) | null;
160
+ gatewayCtx: any;
161
+ /**
162
+ * Per-session E2E state keyed by the app's session UUID.
163
+ * Each app connection gets its own X25519 keypair + AES-256-GCM shared key
164
+ * so multiple users can be active simultaneously without key collisions.
165
+ */
166
+ e2eSessions: Map<string, E2EState>;
167
+ }
168
+
169
+ const relayStates = new Map<string, RelayState>();
170
+
171
+ const RECONNECT_DELAY = 5000;
172
+ const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
173
+ const DEFAULT_ACCOUNT_ID = "default";
174
+ const CHANNEL_ID = "openclaw-mobile";
175
+
176
+ function getRelayState(accountId: string): RelayState {
177
+ let state = relayStates.get(accountId);
178
+ if (!state) {
179
+ state = { ws: null, reconnectTimer: null, pingTimer: null, statusSink: null, gatewayCtx: null, e2eSessions: new Map() };
180
+ relayStates.set(accountId, state);
181
+ }
182
+ return state;
183
+ }
184
+
185
+ // ── Account resolution ───────────────────────────────────────────────────────
186
+
187
+ /** Default relay deployed at https://github.com/openclaw/openclaw-mobile */
188
+ const DEFAULT_RELAY_URL = "wss://openclaw-relay.eldermoo8718.workers.dev";
189
+ const DEFAULT_ROOM_ID = "default";
190
+
191
+ interface ResolvedAccount {
192
+ accountId: string;
193
+ enabled: boolean;
194
+ relayUrl: string;
195
+ relayToken: string;
196
+ roomId: string;
197
+ configured: boolean;
198
+ }
199
+
200
+ function listAccountIds(cfg: any): string[] {
201
+ const ids = Object.keys(
202
+ cfg.channels?.[CHANNEL_ID]?.accounts ?? {}
203
+ );
204
+ // Always expose at least the default account so the Control UI
205
+ // shows a pre-filled entry without the user having to click "Add Entry".
206
+ if (!ids.includes(DEFAULT_ACCOUNT_ID)) ids.unshift(DEFAULT_ACCOUNT_ID);
207
+ return ids;
208
+ }
209
+
210
+ function resolveAccount(cfg: any, accountId?: string): ResolvedAccount {
211
+ const id = accountId ?? DEFAULT_ACCOUNT_ID;
212
+ const acct =
213
+ cfg.channels?.[CHANNEL_ID]?.accounts?.[id] ?? {};
214
+ const relayUrl = acct.relayUrl || DEFAULT_RELAY_URL;
215
+ const roomId = acct.roomId || DEFAULT_ROOM_ID;
216
+ return {
217
+ accountId: id,
218
+ enabled: acct.enabled !== false,
219
+ relayUrl,
220
+ relayToken: acct.relayToken ?? "",
221
+ roomId,
222
+ configured: true, // always ready out of the box with the default relay
223
+ };
224
+ }
225
+
226
+ // ── Channel config schema (JSON Schema for Control UI) ───────────────────────
227
+
228
+ const mobileConfigSchema = {
229
+ schema: {
230
+ $schema: "http://json-schema.org/draft-07/schema#",
231
+ type: "object" as const,
232
+ properties: {
233
+ accounts: {
234
+ type: "object" as const,
235
+ additionalProperties: {
236
+ type: "object" as const,
237
+ properties: {
238
+ enabled: { type: "boolean" as const },
239
+ relayUrl: {
240
+ type: "string" as const,
241
+ description: "CF Worker relay WebSocket URL",
242
+ },
243
+ relayToken: {
244
+ type: "string" as const,
245
+ description: "Shared secret for relay authentication",
246
+ },
247
+ roomId: {
248
+ type: "string" as const,
249
+ description: "Room ID for relay routing",
250
+ },
251
+ },
252
+ additionalProperties: false,
253
+ },
254
+ },
255
+ },
256
+ additionalProperties: false,
257
+ },
258
+ uiHints: {
259
+ relayUrl: {
260
+ label: "Relay URL",
261
+ placeholder: DEFAULT_RELAY_URL,
262
+ },
263
+ relayToken: {
264
+ label: "Relay Token",
265
+ sensitive: true,
266
+ },
267
+ roomId: {
268
+ label: "Room ID",
269
+ placeholder: DEFAULT_ROOM_ID,
270
+ },
271
+ },
272
+ };
273
+
274
+ // ── Channel plugin ───────────────────────────────────────────────────────────
275
+
276
+ const channel = {
277
+ id: CHANNEL_ID,
278
+ meta: {
279
+ id: CHANNEL_ID,
280
+ label: "OpenClaw Mobile",
281
+ selectionLabel: "OpenClaw Mobile App",
282
+ blurb: "Chat via the OpenClaw Mobile app through a relay.",
283
+ detailLabel: "Mobile App",
284
+ aliases: ["mobile"],
285
+ },
286
+ capabilities: {
287
+ chatTypes: ["direct"],
288
+ },
289
+ reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
290
+ configSchema: mobileConfigSchema,
291
+ config: {
292
+ listAccountIds: (cfg: any) => listAccountIds(cfg),
293
+ resolveAccount: (cfg: any, accountId?: string) =>
294
+ resolveAccount(cfg, accountId),
295
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
296
+ setAccountEnabled: ({ cfg, accountId, enabled }: { cfg: any; accountId: string; enabled: boolean }) => {
297
+ const key = accountId || DEFAULT_ACCOUNT_ID;
298
+ const base = cfg.channels?.[CHANNEL_ID] ?? {};
299
+ const accounts = base.accounts ?? {};
300
+ return {
301
+ ...cfg,
302
+ channels: {
303
+ ...cfg.channels,
304
+ [CHANNEL_ID]: {
305
+ ...base,
306
+ accounts: {
307
+ ...accounts,
308
+ [key]: {
309
+ ...accounts[key],
310
+ enabled,
311
+ },
312
+ },
313
+ },
314
+ },
315
+ };
316
+ },
317
+ isConfigured: (account: ResolvedAccount) => account.configured,
318
+ describeAccount: (account: ResolvedAccount) => ({
319
+ accountId: account.accountId,
320
+ enabled: account.enabled,
321
+ configured: account.configured,
322
+ relayUrl: account.relayUrl,
323
+ roomId: account.roomId,
324
+ }),
325
+ },
326
+ outbound: {
327
+ deliveryMode: "direct" as const,
328
+
329
+ sendText: async ({ text, to, accountId, session }: any) => {
330
+ const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
331
+ const state = getRelayState(aid);
332
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
333
+ return { ok: false, error: "relay not connected" };
334
+ }
335
+
336
+ const runtime = pluginRuntime;
337
+ let outText = text ?? "";
338
+ if (runtime) {
339
+ const cfg = runtime.config.loadConfig();
340
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
341
+ cfg,
342
+ channel: CHANNEL_ID,
343
+ accountId: aid,
344
+ });
345
+ outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
346
+ }
347
+
348
+ const replySessionKey = session?.key ?? null;
349
+ const sessionE2E = replySessionKey ? state.e2eSessions.get(replySessionKey) : undefined;
350
+ const plainMsg = JSON.stringify({
351
+ type: "message",
352
+ role: "assistant",
353
+ content: outText,
354
+ sessionKey: replySessionKey,
355
+ });
356
+ let outMsg: string;
357
+ if (sessionE2E?.ready) {
358
+ const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
359
+ encrypted.sessionKey = replySessionKey;
360
+ outMsg = JSON.stringify(encrypted);
361
+ } else {
362
+ outMsg = plainMsg;
363
+ }
364
+ state.ws.send(outMsg);
365
+
366
+ return {
367
+ channel: CHANNEL_ID,
368
+ to: to ?? "mobile-user",
369
+ messageId: `mobile-${Date.now()}`,
370
+ };
371
+ },
372
+ },
373
+
374
+ status: {
375
+ defaultRuntime: {
376
+ accountId: DEFAULT_ACCOUNT_ID,
377
+ running: false,
378
+ connected: false,
379
+ lastConnectedAt: null,
380
+ lastDisconnect: null,
381
+ lastStartAt: null,
382
+ lastStopAt: null,
383
+ lastError: null,
384
+ lastInboundAt: null,
385
+ },
386
+ buildChannelSummary: ({ snapshot }: any) => ({
387
+ configured: snapshot.configured ?? false,
388
+ running: snapshot.running ?? false,
389
+ connected: snapshot.connected ?? false,
390
+ lastInboundAt: snapshot.lastInboundAt ?? null,
391
+ }),
392
+ buildAccountSnapshot: ({ account, runtime }: any) => ({
393
+ accountId: account.accountId,
394
+ enabled: account.enabled,
395
+ configured: account.configured,
396
+ relayUrl: account.relayUrl || "(not set)",
397
+ running: runtime?.running ?? false,
398
+ connected: runtime?.connected ?? false,
399
+ lastStartAt: runtime?.lastStartAt ?? null,
400
+ lastStopAt: runtime?.lastStopAt ?? null,
401
+ lastError: runtime?.lastError ?? null,
402
+ lastInboundAt: runtime?.lastInboundAt ?? null,
403
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
404
+ }),
405
+ },
406
+
407
+ gateway: {
408
+ startAccount: async (ctx: any) => {
409
+ const account: ResolvedAccount = ctx.account;
410
+ const accountId = account.accountId;
411
+ const state = getRelayState(accountId);
412
+
413
+ cleanupRelay(state);
414
+
415
+ state.gatewayCtx = ctx;
416
+ state.statusSink = (patch: Record<string, unknown>) =>
417
+ ctx.setStatus({ accountId, ...patch });
418
+
419
+ ctx.setStatus({ accountId, running: true, lastStartAt: new Date().toISOString() });
420
+
421
+ if (!account.configured) {
422
+ const msg = "relayUrl not configured";
423
+ ctx.setStatus({ accountId, lastError: msg });
424
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
425
+ return;
426
+ }
427
+
428
+ connectRelay(ctx, account);
429
+
430
+ const signal: AbortSignal | undefined = ctx.abortSignal;
431
+ if (signal) {
432
+ await new Promise<void>((resolve) => {
433
+ if (signal.aborted) { resolve(); return; }
434
+ signal.addEventListener("abort", () => resolve(), { once: true });
435
+ });
436
+ cleanupRelay(state);
437
+ ctx.setStatus({ accountId, running: false, connected: false });
438
+ }
439
+ },
440
+ stopAccount: async (ctx: any) => {
441
+ const accountId = ctx.account?.accountId ?? DEFAULT_ACCOUNT_ID;
442
+ const state = getRelayState(accountId);
443
+ cleanupRelay(state);
444
+
445
+ ctx.setStatus?.({
446
+ accountId,
447
+ running: false,
448
+ connected: false,
449
+ lastStopAt: new Date().toISOString(),
450
+ });
451
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] stopped`);
452
+ },
453
+ },
454
+ };
455
+
456
+ // ── Relay bridge ─────────────────────────────────────────────────────────────
457
+
458
+ function cleanupRelay(state: RelayState) {
459
+ if (state.pingTimer) {
460
+ clearInterval(state.pingTimer);
461
+ state.pingTimer = null;
462
+ }
463
+ if (state.reconnectTimer) {
464
+ clearTimeout(state.reconnectTimer);
465
+ state.reconnectTimer = null;
466
+ }
467
+ if (state.ws) {
468
+ try { state.ws.close(); } catch {}
469
+ state.ws = null;
470
+ }
471
+ // Clear all per-session E2E states — new handshakes needed on reconnect
472
+ state.e2eSessions.clear();
473
+ }
474
+
475
+ function connectRelay(ctx: any, account: ResolvedAccount) {
476
+ const accountId = account.accountId;
477
+ const state = getRelayState(accountId);
478
+
479
+ const base = account.relayUrl.replace(/\/$/, "");
480
+ const params = new URLSearchParams({
481
+ role: "plugin",
482
+ room: account.roomId,
483
+ });
484
+ if (account.relayToken) params.set("token", account.relayToken);
485
+
486
+ const url = `${base}/ws?${params.toString()}`;
487
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Connecting to relay: ${url}`);
488
+
489
+ try {
490
+ state.ws = new WebSocket(url);
491
+ } catch (err) {
492
+ const msg = `WebSocket create failed: ${err}`;
493
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] ${msg}`);
494
+ state.statusSink?.({ lastError: msg });
495
+ scheduleReconnect(ctx, account);
496
+ return;
497
+ }
498
+
499
+ state.ws.addEventListener("open", () => {
500
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay connected`);
501
+ if (state.reconnectTimer) {
502
+ clearTimeout(state.reconnectTimer);
503
+ state.reconnectTimer = null;
504
+ }
505
+ if (state.pingTimer) clearInterval(state.pingTimer);
506
+ state.pingTimer = setInterval(() => {
507
+ if (state.ws?.readyState === WebSocket.OPEN) {
508
+ state.ws.send("ping");
509
+ }
510
+ }, PING_INTERVAL);
511
+
512
+ state.statusSink?.({
513
+ connected: true,
514
+ lastConnectedAt: new Date().toISOString(),
515
+ lastError: null,
516
+ });
517
+
518
+ // E2E handshakes are initiated per-session when `peer_joined` arrives
519
+ // (each app connection carries its own sessionKey UUID).
520
+ });
521
+
522
+ state.ws.addEventListener("message", (event: MessageEvent) => {
523
+ handleRelayMessage(ctx, accountId, state, String(event.data)).catch((e) => {
524
+ ctx.log?.error?.(
525
+ `[${CHANNEL_ID}] [${accountId}] Failed to handle relay message: ${e}`
526
+ );
527
+ });
528
+ });
529
+
530
+ state.ws.addEventListener("close", () => {
531
+ ctx.log?.info?.(
532
+ `[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting...`
533
+ );
534
+ state.ws = null;
535
+ if (state.pingTimer) {
536
+ clearInterval(state.pingTimer);
537
+ state.pingTimer = null;
538
+ }
539
+ state.statusSink?.({
540
+ connected: false,
541
+ lastDisconnect: new Date().toISOString(),
542
+ });
543
+ scheduleReconnect(ctx, account);
544
+ });
545
+
546
+ state.ws.addEventListener("error", () => {
547
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
548
+ state.statusSink?.({ lastError: "WebSocket error" });
549
+ });
550
+ }
551
+
552
+ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
553
+ const state = getRelayState(account.accountId);
554
+ if (state.reconnectTimer) return;
555
+ state.reconnectTimer = setTimeout(() => {
556
+ state.reconnectTimer = null;
557
+ connectRelay(ctx, account);
558
+ }, RECONNECT_DELAY);
559
+ }
560
+
561
+ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
562
+ // Skip ping/pong
563
+ if (raw === "ping" || raw === "pong") return;
564
+
565
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Relay message: ${raw.slice(0, 200)}`);
566
+
567
+ const msg = JSON.parse(raw);
568
+
569
+ // App session joined — initiate per-session E2E handshake
570
+ // 프로토콜: plugin이 먼저 handshake를 보내고, app이 자신의 pubkey로 응답.
571
+ // peer_joined가 중복으로 올 수 있으므로 이미 진행 중인 세션은 재시작하지 않음.
572
+ if (msg.type === "peer_joined") {
573
+ const sessionKey = msg.sessionKey as string | undefined;
574
+ if (!sessionKey) {
575
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
576
+ return;
577
+ }
578
+ // 이미 해당 session E2E가 진행 중이면 재시작하지 않음 (중복 peer_joined 방지)
579
+ if (state.e2eSessions.has(sessionKey)) {
580
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already exists, skipping duplicate peer_joined`);
581
+ return;
582
+ }
583
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
584
+ const sessionE2E = makeE2EState();
585
+ state.e2eSessions.set(sessionKey, sessionE2E);
586
+ const handshakeMsg = await e2eInit(sessionE2E);
587
+ // sessionKey를 포함시켜 relay가 올바른 app으로 라우팅하도록 함
588
+ const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
589
+ if (state.ws?.readyState === WebSocket.OPEN) {
590
+ state.ws.send(handshakeWithSession);
591
+ }
592
+ return;
593
+ }
594
+
595
+ // App의 handshake 응답 수신 — ECDH 완성
596
+ if (msg.type === "handshake") {
597
+ const sessionKey = msg.sessionKey as string | undefined;
598
+ const peerPubKey = msg.pubkey as string | undefined;
599
+ if (!sessionKey || !peerPubKey) {
600
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
601
+ return;
602
+ }
603
+ let sessionE2E = state.e2eSessions.get(sessionKey);
604
+ if (!sessionE2E) {
605
+ // peer_joined 없이 app이 먼저 handshake를 보낸 경우 (예: plugin 재연결)
606
+ // 새 세션을 만들고 plugin의 pubkey를 먼저 전송한 후 ECDH 완성
607
+ sessionE2E = makeE2EState();
608
+ state.e2eSessions.set(sessionKey, sessionE2E);
609
+ const handshakeMsg = await e2eInit(sessionE2E);
610
+ const handshakeWithSession = JSON.stringify({ ...JSON.parse(handshakeMsg), sessionKey });
611
+ if (state.ws?.readyState === WebSocket.OPEN) {
612
+ state.ws.send(handshakeWithSession);
613
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} — sent handshake (reactive, no prior peer_joined)`);
614
+ }
615
+ }
616
+ if (sessionE2E.ready) {
617
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already ready, ignoring duplicate handshake`);
618
+ return;
619
+ }
620
+ if (!sessionE2E.localKeyPair) {
621
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} has no local keypair yet, dropping handshake`);
622
+ return;
623
+ }
624
+ await e2eHandleHandshake(sessionE2E, peerPubKey);
625
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
626
+ return;
627
+ }
628
+
629
+ // E2E encrypted message — decrypt using the per-session key
630
+ if (msg.type === "encrypted") {
631
+ const sessionKey = msg.sessionKey as string | undefined;
632
+ if (!sessionKey) {
633
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
634
+ return;
635
+ }
636
+ const sessionE2E = state.e2eSessions.get(sessionKey);
637
+ if (!sessionE2E?.ready) {
638
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
639
+ return;
640
+ }
641
+ const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
642
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
643
+ const innerMsg = JSON.parse(plaintext);
644
+ // Preserve sessionKey through decryption so handleInbound can use it
645
+ if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
646
+ await handleInbound(ctx, accountId, innerMsg);
647
+ return;
648
+ }
649
+
650
+ // Plaintext message (no E2E or during handshake)
651
+ await handleInbound(ctx, accountId, msg);
652
+ }
653
+
654
+ async function handleInbound(ctx: any, accountId: string, msg: any) {
655
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] handleInbound: type=${msg.type} content=${String(msg.content ?? "").slice(0, 100)}`);
656
+
657
+ if (msg.type !== "message" || !msg.content) {
658
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] Skipping non-message: type=${msg.type}`);
659
+ return;
660
+ }
661
+
662
+ const state = getRelayState(accountId);
663
+ state.statusSink?.({ lastInboundAt: new Date().toISOString() });
664
+
665
+ const runtime = pluginRuntime;
666
+ if (!runtime) {
667
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Plugin runtime not available, cannot dispatch inbound message`);
668
+ return;
669
+ }
670
+
671
+ const senderId = msg.senderId ?? "mobile-user";
672
+ const senderName = msg.senderName ?? "Mobile User";
673
+ const text = String(msg.content);
674
+
675
+ try {
676
+ const cfg = runtime.config.loadConfig();
677
+
678
+ const route = runtime.channel.routing.resolveAgentRoute({
679
+ cfg,
680
+ channel: CHANNEL_ID,
681
+ accountId,
682
+ peer: { kind: "direct", id: senderId },
683
+ });
684
+
685
+ // If the app specified a sessionKey (UUID-based), derive an isolated
686
+ // Gateway session so each mobile conversation has its own context.
687
+ // Format: "agent:main:mobile-<uuid>"
688
+ // No appSessionKey → use the route's mainSessionKey (default conversation).
689
+ let sessionKey: string;
690
+ const appSessionKey = msg.sessionKey ? String(msg.sessionKey) : null;
691
+ if (appSessionKey) {
692
+ // Extract agent prefix from mainSessionKey e.g. "agent:main:" from "agent:main:main"
693
+ const mainKey = route.mainSessionKey as string;
694
+ const colonIdx = mainKey.lastIndexOf(':');
695
+ const agentPrefix = colonIdx > 0 ? mainKey.substring(0, colonIdx + 1) : '';
696
+ sessionKey = `${agentPrefix}mobile-${appSessionKey}`;
697
+ } else {
698
+ sessionKey = route.mainSessionKey;
699
+ }
700
+ const from = `${CHANNEL_ID}:${senderId}`;
701
+ const to = `user:${senderId}`;
702
+
703
+ runtime.channel.activity.record({
704
+ channel: CHANNEL_ID,
705
+ accountId,
706
+ direction: "inbound",
707
+ });
708
+
709
+ runtime.system.enqueueSystemEvent(
710
+ `Mobile message from ${senderName}: ${text.slice(0, 160)}`,
711
+ { sessionKey, contextKey: `${CHANNEL_ID}:message:${Date.now()}` },
712
+ );
713
+
714
+ const body = runtime.channel.reply.formatInboundEnvelope({
715
+ channel: "OpenClaw Mobile",
716
+ from: `${senderName} (mobile)`,
717
+ body: text,
718
+ chatType: "direct",
719
+ sender: { name: senderName, id: senderId },
720
+ });
721
+
722
+ const ctxPayload = runtime.channel.reply.finalizeInboundContext({
723
+ Body: body,
724
+ BodyForAgent: text,
725
+ RawBody: text,
726
+ CommandBody: text,
727
+ From: from,
728
+ To: to,
729
+ SessionKey: sessionKey,
730
+ AccountId: accountId,
731
+ ChatType: "direct",
732
+ ConversationLabel: `Mobile DM from ${senderName}`,
733
+ SenderName: senderName,
734
+ SenderId: senderId,
735
+ Provider: CHANNEL_ID,
736
+ Surface: CHANNEL_ID,
737
+ OriginatingChannel: CHANNEL_ID,
738
+ OriginatingTo: to,
739
+ });
740
+
741
+ const textLimit = runtime.channel.text.resolveTextChunkLimit(
742
+ cfg, CHANNEL_ID, accountId, { fallbackLimit: 4000 },
743
+ );
744
+ const tableMode = runtime.channel.text.resolveMarkdownTableMode({
745
+ cfg, channel: CHANNEL_ID, accountId,
746
+ });
747
+
748
+ const { dispatcher, replyOptions, markDispatchIdle } =
749
+ runtime.channel.reply.createReplyDispatcherWithTyping({
750
+ humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
751
+ deliver: async (payload: any) => {
752
+ const relayState = getRelayState(accountId);
753
+ if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
754
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
755
+ return;
756
+ }
757
+ const replyText = runtime.channel.text.convertMarkdownTables(
758
+ payload.text ?? "", tableMode,
759
+ );
760
+ const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
761
+ const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
762
+ // The relay routes plugin→app by sessionKey in the JSON payload
763
+ const replySessionKey = appSessionKey ?? sessionKey;
764
+ const sessionE2E = relayState.e2eSessions.get(replySessionKey);
765
+ for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
766
+ if (!chunk) continue;
767
+ const plainMsg = JSON.stringify({
768
+ type: "message",
769
+ role: "assistant",
770
+ content: chunk,
771
+ sessionKey: replySessionKey,
772
+ });
773
+ // Encrypt with the per-session key if handshake is complete.
774
+ // The outer envelope must carry sessionKey so the relay can route
775
+ // the message to the correct app connection.
776
+ let outMsg: string;
777
+ if (sessionE2E?.ready) {
778
+ const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
779
+ encrypted.sessionKey = replySessionKey;
780
+ outMsg = JSON.stringify(encrypted);
781
+ } else {
782
+ outMsg = plainMsg;
783
+ }
784
+ relayState.ws.send(outMsg);
785
+ }
786
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Reply delivered (${replyText.length} chars) to sessionKey=${replySessionKey}`);
787
+ },
788
+ onError: (err: any, info: any) => {
789
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Reply dispatch error (${info?.kind}): ${err}`);
790
+ },
791
+ });
792
+
793
+ await runtime.channel.reply.dispatchReplyFromConfig({
794
+ ctx: ctxPayload,
795
+ cfg,
796
+ dispatcher,
797
+ replyOptions,
798
+ });
799
+ markDispatchIdle();
800
+
801
+ const sessionCfg = cfg.session;
802
+ const storePath = runtime.channel.session.resolveStorePath(sessionCfg?.store, {
803
+ agentId: route.agentId,
804
+ });
805
+ await runtime.channel.session.updateLastRoute({
806
+ storePath,
807
+ sessionKey,
808
+ deliveryContext: {
809
+ channel: CHANNEL_ID,
810
+ to,
811
+ accountId,
812
+ },
813
+ });
814
+
815
+ ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Inbound message processed for session ${sessionKey}`);
816
+ } catch (e) {
817
+ ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Error processing inbound message: ${e}`);
818
+ }
819
+ }
820
+
821
+ // ── Plugin entry ─────────────────────────────────────────────────────────────
822
+
823
+ export default function register(api: any) {
824
+ pluginRuntime = api.runtime;
825
+ api.registerChannel({ plugin: channel });
826
+
827
+ // On gateway start, ensure the default account config exists so the
828
+ // Control UI shows pre-filled values without any manual JSON editing.
829
+ api.on("gateway_start", async () => {
830
+ try {
831
+ const runtime = pluginRuntime;
832
+ if (!runtime) return;
833
+
834
+ const cfg = runtime.config.loadConfig();
835
+ const existing = cfg.channels?.[CHANNEL_ID]?.accounts?.[DEFAULT_ACCOUNT_ID];
836
+
837
+ // Only write defaults if the account entry is completely absent
838
+ if (existing !== undefined) return;
839
+
840
+ const patched = {
841
+ ...cfg,
842
+ channels: {
843
+ ...cfg.channels,
844
+ [CHANNEL_ID]: {
845
+ ...(cfg.channels?.[CHANNEL_ID] ?? {}),
846
+ accounts: {
847
+ ...(cfg.channels?.[CHANNEL_ID]?.accounts ?? {}),
848
+ [DEFAULT_ACCOUNT_ID]: {
849
+ enabled: true,
850
+ relayUrl: DEFAULT_RELAY_URL,
851
+ roomId: DEFAULT_ROOM_ID,
852
+ },
853
+ },
854
+ },
855
+ },
856
+ };
857
+
858
+ await runtime.config.writeConfigFile(patched);
859
+ api.logger?.info?.(`[openclaw-mobile] Wrote default account config (relayUrl=${DEFAULT_RELAY_URL}, roomId=${DEFAULT_ROOM_ID})`);
860
+ } catch (err) {
861
+ api.logger?.warn?.(`[openclaw-mobile] Could not write default config: ${err}`);
862
+ }
863
+ });
864
+
865
+ api.logger?.info?.("[openclaw-mobile] Plugin registered");
866
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "id": "openclaw-app",
3
+ "name": "OpenClaw App",
4
+ "version": "1.0.0",
5
+ "description": "Mobile app channel for OpenClaw — chat via the OpenClaw Mobile app through a Cloudflare Worker relay.",
6
+ "channels": [
7
+ "openclaw-mobile"
8
+ ],
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "relayUrl": {
14
+ "type": "string",
15
+ "description": "CF Worker relay WebSocket URL (e.g. wss://openclaw-relay.xxx.workers.dev)"
16
+ },
17
+ "relayToken": {
18
+ "type": "string",
19
+ "description": "Shared secret for relay authentication"
20
+ },
21
+ "roomId": {
22
+ "type": "string",
23
+ "description": "Room ID for relay routing",
24
+ "default": "default"
25
+ }
26
+ }
27
+ },
28
+ "uiHints": {
29
+ "relayUrl": {
30
+ "label": "Relay URL",
31
+ "placeholder": "wss://openclaw-relay.xxx.workers.dev"
32
+ },
33
+ "relayToken": {
34
+ "label": "Relay Token",
35
+ "sensitive": true
36
+ },
37
+ "roomId": {
38
+ "label": "Room ID",
39
+ "placeholder": "default"
40
+ }
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "openclaw-app",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw Mobile channel plugin — relay bridge for the OpenClaw Mobile app",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "openclaw": {
8
+ "plugin": "./openclaw.plugin.json",
9
+ "extensions": [
10
+ "./index.ts"
11
+ ]
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "plugin",
16
+ "channel",
17
+ "mobile"
18
+ ],
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "typescript": "^5.7.0"
22
+ },
23
+ "peerDependencies": {
24
+ "openclaw": "*"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "openclaw": {
28
+ "optional": true
29
+ }
30
+ }
31
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ // Visit https://aka.ms/tsconfig to read more about this file
3
+ "compilerOptions": {
4
+ // File Layout
5
+ // "rootDir": "./src",
6
+ // "outDir": "./dist",
7
+
8
+ // Environment Settings
9
+ // See also https://aka.ms/tsconfig/module
10
+ "module": "nodenext",
11
+ "target": "esnext",
12
+ "types": [],
13
+ // For nodejs:
14
+ // "lib": ["esnext"],
15
+ // "types": ["node"],
16
+ // and npm install -D @types/node
17
+
18
+ // Other Outputs
19
+ "sourceMap": true,
20
+ "declaration": true,
21
+ "declarationMap": true,
22
+
23
+ // Stricter Typechecking Options
24
+ "noUncheckedIndexedAccess": true,
25
+ "exactOptionalPropertyTypes": true,
26
+
27
+ // Style Options
28
+ // "noImplicitReturns": true,
29
+ // "noImplicitOverride": true,
30
+ // "noUnusedLocals": true,
31
+ // "noUnusedParameters": true,
32
+ // "noFallthroughCasesInSwitch": true,
33
+ // "noPropertyAccessFromIndexSignature": true,
34
+
35
+ // Recommended Options
36
+ "strict": true,
37
+ "jsx": "react-jsx",
38
+ "verbatimModuleSyntax": true,
39
+ "isolatedModules": true,
40
+ "noUncheckedSideEffectImports": true,
41
+ "moduleDetection": "force",
42
+ "skipLibCheck": true,
43
+ }
44
+ }