@lingxia/skill 0.8.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.
Files changed (37) hide show
  1. package/README.md +95 -0
  2. package/bin/install.mjs +247 -0
  3. package/package.json +49 -0
  4. package/scripts/sync.mjs +69 -0
  5. package/skill/SKILL.md +334 -0
  6. package/skill/app/apple-sdk.md +312 -0
  7. package/skill/app/applinks.md +289 -0
  8. package/skill/app/project.md +760 -0
  9. package/skill/cli/lxdev.md +195 -0
  10. package/skill/cli/reference.md +481 -0
  11. package/skill/examples/hello-host-js/README.md +25 -0
  12. package/skill/examples/hello-host-js/home/lxapp.json +12 -0
  13. package/skill/examples/hello-host-js/home/pages/home/index.json +4 -0
  14. package/skill/examples/hello-host-js/home/pages/home/index.ts +14 -0
  15. package/skill/examples/hello-host-js/home/pages/home/index.tsx +15 -0
  16. package/skill/examples/hello-host-js/lingxia.yaml +39 -0
  17. package/skill/examples/hello-host-rust/Cargo.toml +15 -0
  18. package/skill/examples/hello-host-rust/README.md +44 -0
  19. package/skill/examples/hello-host-rust/home/lxapp.json +13 -0
  20. package/skill/examples/hello-host-rust/home/pages/home/index.html +46 -0
  21. package/skill/examples/hello-host-rust/home/pages/home/index.json +4 -0
  22. package/skill/examples/hello-host-rust/lingxia.yaml +32 -0
  23. package/skill/examples/hello-host-rust/src/lib.rs +58 -0
  24. package/skill/examples/hello-lxapp/README.md +29 -0
  25. package/skill/examples/hello-lxapp/lxapp.config.ts +8 -0
  26. package/skill/examples/hello-lxapp/lxapp.json +14 -0
  27. package/skill/examples/hello-lxapp/package.json +14 -0
  28. package/skill/examples/hello-lxapp/pages/home/index.json +4 -0
  29. package/skill/examples/hello-lxapp/pages/home/index.ts +35 -0
  30. package/skill/examples/hello-lxapp/pages/home/index.tsx +34 -0
  31. package/skill/lxapp/bridge.md +654 -0
  32. package/skill/lxapp/components.md +375 -0
  33. package/skill/lxapp/guide.md +675 -0
  34. package/skill/lxapp/lx-api.md +481 -0
  35. package/skill/native/development.md +414 -0
  36. package/skill/reference/file-lifecycle.md +325 -0
  37. package/skill/skill-manifest.json +6 -0
@@ -0,0 +1,654 @@
1
+ # Bridge API for JS Developers
2
+
3
+ This guide explains how View and Logic communicate through the bridge — covering `setData`, stream, and channel. It is written for developers writing lxapp pages, not for implementers of the bridge itself.
4
+
5
+ For a broad introduction to the View/Logic split, see [LxApp Development Guide](./guide.md).
6
+
7
+ ---
8
+
9
+ ## The Bridge Model
10
+
11
+ Every lxapp page has two layers:
12
+
13
+ ```
14
+ ┌─────────────────────────────┐ ┌──────────────────────────────┐
15
+ │ View (WebView) │ │ Logic (Native JS Runtime) │
16
+ │ React / Vue component │ ◄──── bridge ──► Page({}) instance │
17
+ └─────────────────────────────┘ └──────────────────────────────┘
18
+ ```
19
+
20
+ **Logic** owns all business state and operations. It runs in a native JS runtime, not in the WebView. **View** renders UI and reacts to user input. It runs in the WebView and has no direct access to Logic's data.
21
+
22
+ The bridge is the only path between them. It carries three categories of data:
23
+
24
+ | Category | Direction | When to use |
25
+ |---|---|---|
26
+ | **State** (`setData`) | Logic → View | Durable page state: counters, lists, flags |
27
+ | **Stream** (`yield` / `stream.send`) | Logic → View | Incremental output: tokens, progress, chunks |
28
+ | **Channel** (`ch.send`) | bidirectional | Long-lived sessions: real-time sync, collaboration |
29
+
30
+ These three cover every communication pattern. Choosing the right one for a given scenario keeps the architecture clean and performant.
31
+
32
+ ---
33
+
34
+ ## State — `setData`
35
+
36
+ `setData` is the primary mechanism for Logic to push data to View. It merges a partial object into `this.data` and replicates the updated state to the WebView.
37
+
38
+ ### Logic side
39
+
40
+ ```ts
41
+ // pages/counter/index.ts
42
+ Page({
43
+ data: {
44
+ count: 0,
45
+ label: 'Start',
46
+ },
47
+
48
+ increment() {
49
+ this.setData({ count: this.data.count + 1 });
50
+ },
51
+
52
+ reset() {
53
+ this.setData({ count: 0, label: 'Start' });
54
+ },
55
+ });
56
+ ```
57
+
58
+ Rules:
59
+ - `this.data` is read-only. Never mutate it directly — use `setData`.
60
+ - `setData` accepts a partial object. Only the listed keys are updated; the rest are unchanged.
61
+ - The call is synchronous on the Logic side. Replication to View is asynchronous.
62
+
63
+ ### View side
64
+
65
+ ```tsx
66
+ // pages/counter/index.tsx
67
+ import { useLxPage } from '@lingxia/react';
68
+
69
+ type PageData = { count: number; label: string };
70
+
71
+ export default function Counter() {
72
+ const { data, actions } = useLxPage<
73
+ PageData,
74
+ {
75
+ increment: () => void;
76
+ reset: () => void;
77
+ }
78
+ >();
79
+
80
+ return (
81
+ <div>
82
+ <p>{data.label}: {data.count}</p>
83
+ <button onClick={() => actions.increment()}>+1</button>
84
+ <button onClick={() => actions.reset()}>Reset</button>
85
+ </div>
86
+ );
87
+ }
88
+ ```
89
+
90
+ `useLxPage().data` reflects whatever Logic has replicated. It updates reactively — no polling, no manual subscription.
91
+
92
+ ### How replication works
93
+
94
+ Under the hood, `setData` produces a JSON Patch diff and delivers it to View via `state.patch` frames. View applies the patch and triggers a re-render. This diff-based approach is efficient for low-frequency state transitions, but it is not designed for high-frequency payloads — for that, use stream.
95
+
96
+ ```
97
+ Logic: this.setData({ count: 1 })
98
+
99
+ ▼ (JSON Patch diff computed)
100
+ Bridge: state.patch { ops: [{ op:"replace", path:"/count", value:1 }] }
101
+
102
+
103
+ View: data.count === 1 → re-render
104
+ ```
105
+
106
+ ### When to use `setData`
107
+
108
+ - Page state that must persist across navigation and be restorable (e.g., a message list, user profile, form values).
109
+ - State that must outlive a stream or channel session (e.g., saving the final output after a stream completes).
110
+ - Any data the View needs to render its initial or resting state.
111
+
112
+ Do **not** use `setData` for per-chunk stream output. The diff cost and delivery cycle make it unsuitable for hot-path data.
113
+
114
+ ---
115
+
116
+ ## Stream
117
+
118
+ A stream is a one-shot, View-initiated operation where Logic produces a sequence of chunks and terminates. The pattern is `request → events* → done`.
119
+
120
+ Use streams when:
121
+ - Logic performs a long operation and View needs progress updates (file processing, LLM token output, multi-step calculations).
122
+ - The output is incremental and the client should start rendering before completion.
123
+
124
+ ### Logic side — generator form
125
+
126
+ The simplest form — no imports, no special API. Write a standard `async *` generator method on your `Page({})` and the runtime detects it automatically via `Symbol.asyncIterator`. Each `yield` becomes an event frame delivered to View; `return` ends the stream. The `examples/lingxia-showcase/pages/stream` demo uses this pattern with `onSend`.
127
+
128
+ ```ts
129
+ type ChatChunk =
130
+ | { type: 'token'; token: string }
131
+ | { type: 'artifact'; chart: ChartData };
132
+
133
+ async function* mockChatStream(): AsyncGenerator<ChatChunk, void> {
134
+ yield { type: 'token', token: 'Hello ' };
135
+ yield { type: 'token', token: 'from ' };
136
+ yield { type: 'token', token: 'LingXia.' };
137
+ }
138
+
139
+ Page({
140
+ data: {
141
+ messages: [] as Message[],
142
+ isStreaming: false,
143
+ },
144
+
145
+ async *onSend(params: { text: string }) {
146
+ const text = (params?.text ?? '').trim();
147
+ if (!text || this.data.isStreaming) return;
148
+
149
+ const userMsg: Message = {
150
+ id: `u${Date.now()}`,
151
+ role: 'user',
152
+ content: text,
153
+ };
154
+ this.setData({
155
+ messages: [...this.data.messages, userMsg],
156
+ isStreaming: true,
157
+ });
158
+
159
+ let accumulated = '';
160
+ let chartData: ChartData | undefined;
161
+
162
+ try {
163
+ for await (const chunk of mockChatStream()) {
164
+ if (chunk.type === 'token') accumulated += chunk.token;
165
+ if (chunk.type === 'artifact') chartData = chunk.chart;
166
+ yield chunk;
167
+ }
168
+ } finally {
169
+ const assistantMsg: Message = {
170
+ id: `a${Date.now()}`,
171
+ role: 'assistant',
172
+ content: accumulated || '(no response)',
173
+ chart: chartData,
174
+ };
175
+ this.setData({
176
+ messages: [...this.data.messages, assistantMsg],
177
+ isStreaming: false,
178
+ });
179
+ }
180
+ },
181
+ });
182
+ ```
183
+
184
+ The real chat example optionally probes an app-installed AI extension before falling back to mock data, but that extension is app-specific and not part of LingXia's built-in bridge API.
185
+
186
+ ### Logic side — explicit handle form
187
+
188
+ Use this when your async source uses callbacks rather than an async iterator, or when you need to react to cancellation explicitly. You do not import `StreamHandle` — the runtime creates and injects it as the second parameter automatically for methods listed in the page's `stream_handlers` metadata.
189
+
190
+ ```ts
191
+ Page({
192
+ async onProcess(params: { fileId: string }, stream: StreamHandle) {
193
+ const job = lx.files.process(params.fileId);
194
+
195
+ job.on('progress', (pct) => stream.send({ type: 'progress', pct }));
196
+ job.on('done', (out) => stream.end(out));
197
+ job.on('error', (err) => stream.error('PROCESS_FAILED', err.message));
198
+
199
+ stream.on('cancel', () => job.abort());
200
+ },
201
+ });
202
+ ```
203
+
204
+ `StreamHandle` interface:
205
+
206
+ ```ts
207
+ interface StreamHandle {
208
+ send(payload: unknown): void; // send a chunk to View
209
+ end(result?: unknown): void; // end the stream with an optional final value
210
+ error(code: string, msg?: string): void; // end with an error
211
+ on(event: 'cancel', handler: () => void): this; // fires when View cancels
212
+ }
213
+ ```
214
+
215
+ The runtime distinguishes the two forms automatically. You declare which methods use explicit handles in the page metadata (`stream_handlers`); everything else that returns an `AsyncGenerator` uses the generator path.
216
+
217
+ ### View side
218
+
219
+ ```tsx
220
+ import { useState } from 'react';
221
+ import { useLxPage, useLxStream } from '@lingxia/react';
222
+ import type { LxStream } from '@lingxia/bridge';
223
+
224
+ type StreamState = { text: string; chart?: ChartData };
225
+
226
+ export default function ChatPage() {
227
+ const { data, actions } = useLxPage<
228
+ { messages: Message[] },
229
+ {
230
+ onSend: (params: { text: string }) => LxStream<ChatChunk, void>;
231
+ onClear: () => void;
232
+ }
233
+ >();
234
+
235
+ const [inputText, setInputText] = useState('');
236
+
237
+ const chat = useLxStream<typeof actions.onSend, StreamState>(
238
+ actions.onSend,
239
+ {
240
+ params: () => ({ text: inputText }),
241
+ manual: true,
242
+ initial: { text: '' },
243
+ reduce: (acc, chunk) => {
244
+ if (chunk.type === 'token') return { ...acc, text: acc.text + chunk.token };
245
+ if (chunk.type === 'artifact') return { ...acc, chart: chunk.chart };
246
+ return acc;
247
+ },
248
+ },
249
+ );
250
+
251
+ const handleSend = () => {
252
+ const text = inputText.trim();
253
+ if (!text || chat.streaming) return;
254
+ chat.start();
255
+ setInputText('');
256
+ };
257
+
258
+ return (
259
+ <div>
260
+ <MessageList messages={data.messages} />
261
+ {chat.streaming && <StreamingBubble text={chat.data.text} />}
262
+ <input value={inputText} onChange={e => setInputText(e.target.value)} />
263
+ <button onClick={handleSend} disabled={chat.streaming}>Send</button>
264
+ {chat.streaming && <button onClick={() => chat.cancel()}>Stop</button>}
265
+ </div>
266
+ );
267
+ }
268
+ ```
269
+
270
+ `useLxStream` state:
271
+
272
+ | Field | Type | Description |
273
+ |---|---|---|
274
+ | `data` | accumulated via `reduce`, or latest chunk | What to render while streaming |
275
+ | `result` | final value | Returned by `stream.end(result)` or generator return |
276
+ | `error` | `LxBridgeError \| undefined` | Set if stream ended with an error or was canceled |
277
+ | `streaming` | `boolean` | `true` while stream is active |
278
+ | `start()` | `() => void` | Start the stream (when `manual: true`) |
279
+ | `cancel()` | `() => void` | Cancel and clean up |
280
+
281
+ Options:
282
+ - `manual: true` — stream doesn't start until you call `chat.start()`. With `manual: false` (default), it starts on mount and cancels on unmount.
283
+ - `initial` — initial `data` value before the first chunk arrives.
284
+ - `reduce` — accumulator function. If omitted, `data` is simply the latest chunk.
285
+
286
+ ### `setData` vs `yield` — which to use during a stream
287
+
288
+ Both can push data to View, but they are for different things:
289
+
290
+ | | `setData` | `yield` / `stream.send` |
291
+ |---|---|---|
292
+ | Transport | JSON Patch diff | Direct payload, no diff |
293
+ | Delivery | Batched state cycle | Immediate |
294
+ | Use for | State that outlives the stream | Per-chunk hot-path data |
295
+
296
+ **Rule of thumb**: `yield` every chunk. Use `setData` for state transitions that should persist after the stream ends — saving the final message, clearing a loading flag.
297
+
298
+ ### Data flow
299
+
300
+ ```
301
+ View: chat.start() → actions.onSend({ text })
302
+
303
+ ▼ (req frame)
304
+ Bridge → Logic: invoke async generator
305
+
306
+ ▼ generator yields { type:'token', token:'H' }
307
+ Bridge: event frame { seq:0, payload:{ type:'token', token:'H' } }
308
+
309
+
310
+ View: reduce(acc, chunk) → chat.data.text = 'H' → re-render
311
+
312
+ ... more yields ...
313
+
314
+ generator returns
315
+
316
+ ▼ (res frame, ok:true)
317
+ View: chat.streaming = false
318
+
319
+ (if View cancels)
320
+ View: chat.cancel() → cancel frame
321
+
322
+
323
+ Logic: generator.return() → finally block executes
324
+
325
+ ▼ (res frame, ok:false, BRIDGE_CANCELED)
326
+ View: chat.streaming = false, chat.error set
327
+ ```
328
+
329
+ ---
330
+
331
+ ## Channel
332
+
333
+ A channel is a long-lived, bidirectional session between View and Logic. Either side can send messages at any time after the channel is open.
334
+
335
+ Use channels when:
336
+ - The connection must persist for the duration of a user interaction session (collaborative editing, real-time sync, live data feeds).
337
+ - Logic needs to push multiple unsolicited updates while the session is active.
338
+ - View needs to send multiple commands to Logic over time.
339
+
340
+ ### Logic side
341
+
342
+ You do not import `ChannelHandle` — the runtime creates and injects it as the second parameter when View opens a channel. The runtime routes `ch.open` frames by topic (derived from the method name) and invokes the handler.
343
+
344
+ ```ts
345
+ Page({
346
+ syncSession(params: { sessionId: string }, ch: ChannelHandle) {
347
+ const session = lx.sessions.open(params.sessionId);
348
+
349
+ // Logic → View: push updates when they happen
350
+ session.onUpdate(update => ch.send({ type: 'update', update }));
351
+ session.onEvent(event => ch.send({ type: 'event', event }));
352
+
353
+ // Send initial state when channel opens
354
+ ch.send({ type: 'init', state: session.state, rev: session.rev });
355
+
356
+ // Receive messages from View
357
+ ch.on('data', (msg) => {
358
+ if (msg.type === 'op') {
359
+ const result = session.apply(msg.op);
360
+ ch.send({ type: 'ack', rev: result.rev });
361
+ }
362
+ });
363
+
364
+ // Cleanup when channel closes
365
+ ch.on('close', () => {
366
+ session.release();
367
+ });
368
+ },
369
+ });
370
+ ```
371
+
372
+ The handler function receives `ChannelHandle` as its second parameter. Use `ch.send()` to push data to View, and `ch.on()` to register listeners for incoming data and close events. This is the same event-listener pattern used throughout LingXia.
373
+
374
+ `ChannelHandle` interface (injected by runtime):
375
+
376
+ ```ts
377
+ interface ChannelHandle<TSend = unknown, TReceive = unknown> {
378
+ send(payload: TSend): void; // push to View
379
+ close(code?: string, reason?: string): void; // Logic closes the channel
380
+ on(event: 'data', handler: (payload: TReceive) => void): void; // receive from View
381
+ on(event: 'close', handler: (info: { code: string; reason: string }) => void): void;
382
+ }
383
+ ```
384
+
385
+ ### View side
386
+
387
+ ```tsx
388
+ import { useEffect } from 'react';
389
+ import { useLxPage, useLxChannel } from '@lingxia/react';
390
+ import type { LxChannel } from '@lingxia/bridge';
391
+
392
+ export default function EditorPage() {
393
+ const { actions } = useLxPage<
394
+ {},
395
+ { syncSession: (p: { sessionId: string }) => Promise<LxChannel<SessionMessage, SessionCommand>> }
396
+ >();
397
+
398
+ const session = useLxChannel(
399
+ actions.syncSession,
400
+ { params: () => ({ sessionId: 'doc-123' }) },
401
+ );
402
+
403
+ // Handle incoming messages from Logic
404
+ useEffect(() => {
405
+ if (!session.last) return;
406
+ const msg = session.last;
407
+ if (msg.type === 'init') applyInitialState(msg.state);
408
+ if (msg.type === 'update') applyUpdate(msg.update);
409
+ }, [session.last]);
410
+
411
+ const sendOp = (op: Op) => {
412
+ session.send({ type: 'op', op });
413
+ };
414
+
415
+ return (
416
+ <div>
417
+ {session.connecting && <p>Connecting...</p>}
418
+ <Editor onOp={sendOp} />
419
+ <button onClick={() => session.close()}>End session</button>
420
+ </div>
421
+ );
422
+ }
423
+ ```
424
+
425
+ `useLxChannel` state:
426
+
427
+ | Field | Type | Description |
428
+ |---|---|---|
429
+ | `last` | latest received message | Updated on each `ch.data` from Logic |
430
+ | `error` | `LxBridgeError \| undefined` | Set if channel establishment failed or closed with error |
431
+ | `connecting` | `boolean` | `true` while the initial channel open is in flight |
432
+ | `connected` | `boolean` | `true` after `ch.ack`, `false` after `ch.close` |
433
+ | `send(payload)` | `(unknown) => void` | Send a message to Logic |
434
+ | `close()` | `() => void` | Close the channel from View |
435
+ | `reopen()` | `() => void` | Re-open (useful after error, or with `manual: true`) |
436
+
437
+ The channel re-opens automatically when `params` changes. Pass `{ manual: true }` to control open timing yourself and call `reopen()` manually.
438
+
439
+ ### Push during a channel session: `ch.send`, not `setData`
440
+
441
+ Within an open channel, Logic-to-View pushes go through `ch.send`. Do not use `setData` for high-frequency in-session events.
442
+
443
+ `ch.send` delivers directly, without the diff cost or delivery batch of `state.patch`. Use `setData` only for state that must survive the channel — for example, a badge count that reflects an external change that happened while no channel was active.
444
+
445
+ ### Multiplexed message types
446
+
447
+ One channel carries multiple message types via discriminated union. This avoids opening parallel channels for related concerns.
448
+
449
+ ```ts
450
+ // All of these flow through a single channel:
451
+ ch.send({ type: 'init', state, rev });
452
+ ch.send({ type: 'update', update });
453
+ ch.send({ type: 'ack', rev });
454
+ ch.send({ type: 'error', reason });
455
+ ```
456
+
457
+ On the View side, switch on `msg.type` to route each frame.
458
+
459
+ ### Channel lifecycle
460
+
461
+ ```
462
+ View: useLxChannel opens
463
+
464
+ ▼ ch.open frame
465
+ Bridge → Logic: invoke syncSession(params, ch)
466
+
467
+ ▼ ch.ack { ok: true }
468
+ View: session.connected = true
469
+
470
+ ┌─────────────────────────────────┐
471
+ │ bidirectional data exchange │
472
+ │ ch.data (both directions) │
473
+ └─────────────────────────────────┘
474
+
475
+ ▼ ch.close (either side)
476
+ View: session.connected = false
477
+ Logic: ch.on('close') listener fires → cleanup
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Choosing the Right Primitive
483
+
484
+ | Scenario | Use |
485
+ |---|---|
486
+ | Counter, form values, lists | `setData` |
487
+ | LLM token streaming | `stream` (generator) |
488
+ | File processing with progress | `stream` (explicit handle) |
489
+ | Save final output after streaming | `setData` in `finally` block |
490
+ | Real-time collaborative editing | `channel` |
491
+ | Live sensor data feed | `channel` |
492
+ | Device event subscription (internal) | Logic subscribes internally, exposes via `setData` |
493
+
494
+ **Note on subscriptions**: Logic subscribes to external systems (sensors, push, backend events) internally and surfaces results through `setData`. Subscription APIs are not exposed to View — that would move resource ownership to the wrong layer.
495
+
496
+ ---
497
+
498
+ ## Error Handling
499
+
500
+ All three primitives surface errors via `LxBridgeError`:
501
+
502
+ ```ts
503
+ interface LxBridgeError {
504
+ code: string; // see error codes below
505
+ message?: string;
506
+ }
507
+ ```
508
+
509
+ Common error codes:
510
+
511
+ | Code | Meaning |
512
+ |---|---|
513
+ | `BRIDGE_CANCELED` | stream or request was canceled |
514
+ | `BRIDGE_METHOD_NOT_FOUND` | method name doesn't match any Logic handler |
515
+ | `BRIDGE_TOPIC_NOT_FOUND` | channel topic not registered |
516
+ | `BRIDGE_TIMEOUT` | request timed out |
517
+ | `BRIDGE_INTERNAL_ERROR` | unexpected error in Logic or Bridge |
518
+
519
+ For streams, check `chat.error` after `chat.streaming` becomes `false`. For channels, check `session.error` after `session.connected` becomes `false`.
520
+
521
+ ---
522
+
523
+ ## Quick Reference
524
+
525
+ ### Logic
526
+
527
+ ```ts
528
+ // State
529
+ this.setData({ key: value });
530
+ this.data.key; // read current state
531
+
532
+ // Stream — generator form
533
+ async *onStream(params) {
534
+ yield chunk; // send chunk to View
535
+ // return → stream ends
536
+ }
537
+
538
+ // Stream — explicit handle
539
+ async onStream(params, stream: StreamHandle) {
540
+ stream.send(chunk);
541
+ stream.end(finalValue);
542
+ stream.on('cancel', () => { /* cleanup */ });
543
+ }
544
+
545
+ // Channel
546
+ onChannel(params, ch: ChannelHandle) {
547
+ ch.send(msg); // push to View
548
+ ch.on('data', (msg) => {}); // receive from View
549
+ ch.on('close', () => {}); // cleanup
550
+ }
551
+ ```
552
+
553
+ ### View (React)
554
+
555
+ ```tsx
556
+ // State
557
+ const { data, actions } = useLxPage();
558
+ // data updates reactively when Logic calls setData
559
+
560
+ // Stream
561
+ const s = useLxStream(actions.onStream, {
562
+ params: () => params,
563
+ manual: true,
564
+ initial: initialData,
565
+ reduce: (acc, chunk) => newAcc,
566
+ });
567
+ s.data; s.streaming; s.error; s.result;
568
+ s.start(); s.cancel();
569
+
570
+ // Channel
571
+ const ch = useLxChannel(actions.onChannel, { params: () => params });
572
+ ch.last; ch.connecting; ch.connected; ch.error;
573
+ ch.send(msg); ch.close(); ch.reopen();
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Appendix: Stream Data Flow
579
+
580
+ Complete trace of a single `yield { type:'token', token:'H' }` in the chat example:
581
+
582
+ ```
583
+ Logic (QuickJS) Bridge (Rust) View (WebView)
584
+ ─────────────────────────────────────────────────────────────────────────────────
585
+
586
+ actions.onSend({ text })
587
+
588
+ bridge.stream(
589
+ "onSend", { text })
590
+
591
+ ┌─ req ──────▼────────────┐
592
+ │ kind:"req" │
593
+ │ id:"r1" │
594
+ │ method:"onSend" │
595
+ │ params:{ text } │
596
+ └──────────┬──────────────┘
597
+ ◄─────────────────────────────────┘
598
+ not host.* → forward to Logic QuickJS
599
+
600
+ ◄─────────────────┘
601
+ find method "onSend", detect AsyncIterator on return value
602
+ generator = onSend(params)
603
+
604
+ generator.next()
605
+
606
+ ┌───────────▼──────────────────────┐
607
+ │ async *onSend(params) { │
608
+ │ yield { type:'token',token:'H'}│ ← pauses; runtime takes yielded value
609
+ │ } │
610
+ └───────────┬──────────────────────┘
611
+ │ serialize → event frame
612
+
613
+ ┌─ event ──────────────────────┐
614
+ │ kind:"event" │
615
+ │ id:"r1" │
616
+ │ seq:0 │
617
+ │ payload:{type:"token", │
618
+ │ token:"H"} │
619
+ └──────────┬───────────────────┘
620
+ ├──────────────────────────────────────────────►
621
+ bridge receives event
622
+ StreamHandle._emitData(payload)
623
+ useLxStream listener:
624
+ reduce: acc + chunk.token
625
+ setState → re-render
626
+ chat.data.text = 'H'
627
+
628
+ generator.next() continues...
629
+ yield {token:'e'} → event{seq:1,payload:{token:'e'}} → chat.data.text = 'He'
630
+ ...
631
+ generator returns → res{ok:true, payload:null} → chat.streaming = false
632
+
633
+ cancel path:
634
+ chat.cancel()
635
+
636
+ ┌─ cancel ───▼──┐
637
+ │ kind:"cancel" │
638
+ │ id:"r1" │
639
+ └──────┬────────┘
640
+ ◄────────────────────────────────┘
641
+ runtime: generator.return()
642
+ finally block executes (setData, resource cleanup)
643
+ → res{ok:false, error:{code:"BRIDGE_CANCELED"}}
644
+ on('error') → chat.streaming = false
645
+ ```
646
+
647
+ Key points:
648
+
649
+ - `yield value` → runtime serializes to `event` frame → Rust bridge → View. No diff, no patch.
650
+ - `setData` → `state.patch` with JSON Patch diff. Use for low-frequency state transitions, not per-chunk.
651
+ - `seq` increments per `yield`, guaranteeing ordered delivery regardless of async timing.
652
+ - Generator `return` → `res{ok:true}`. Unhandled exception → `res{ok:false, error}`.
653
+ - View `cancel()` → `cancel` frame → `generator.return()` → `finally` block executes cleanup.
654
+ - `event.payload` matches Bridge Protocol V2 wire format.