@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.
- package/README.md +95 -0
- package/bin/install.mjs +247 -0
- package/package.json +49 -0
- package/scripts/sync.mjs +69 -0
- package/skill/SKILL.md +334 -0
- package/skill/app/apple-sdk.md +312 -0
- package/skill/app/applinks.md +289 -0
- package/skill/app/project.md +760 -0
- package/skill/cli/lxdev.md +195 -0
- package/skill/cli/reference.md +481 -0
- package/skill/examples/hello-host-js/README.md +25 -0
- package/skill/examples/hello-host-js/home/lxapp.json +12 -0
- package/skill/examples/hello-host-js/home/pages/home/index.json +4 -0
- package/skill/examples/hello-host-js/home/pages/home/index.ts +14 -0
- package/skill/examples/hello-host-js/home/pages/home/index.tsx +15 -0
- package/skill/examples/hello-host-js/lingxia.yaml +39 -0
- package/skill/examples/hello-host-rust/Cargo.toml +15 -0
- package/skill/examples/hello-host-rust/README.md +44 -0
- package/skill/examples/hello-host-rust/home/lxapp.json +13 -0
- package/skill/examples/hello-host-rust/home/pages/home/index.html +46 -0
- package/skill/examples/hello-host-rust/home/pages/home/index.json +4 -0
- package/skill/examples/hello-host-rust/lingxia.yaml +32 -0
- package/skill/examples/hello-host-rust/src/lib.rs +58 -0
- package/skill/examples/hello-lxapp/README.md +29 -0
- package/skill/examples/hello-lxapp/lxapp.config.ts +8 -0
- package/skill/examples/hello-lxapp/lxapp.json +14 -0
- package/skill/examples/hello-lxapp/package.json +14 -0
- package/skill/examples/hello-lxapp/pages/home/index.json +4 -0
- package/skill/examples/hello-lxapp/pages/home/index.ts +35 -0
- package/skill/examples/hello-lxapp/pages/home/index.tsx +34 -0
- package/skill/lxapp/bridge.md +654 -0
- package/skill/lxapp/components.md +375 -0
- package/skill/lxapp/guide.md +675 -0
- package/skill/lxapp/lx-api.md +481 -0
- package/skill/native/development.md +414 -0
- package/skill/reference/file-lifecycle.md +325 -0
- 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.
|