@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.2
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/Package.swift +6 -2
- package/README.md +97 -9
- package/android/build.gradle.kts +5 -5
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +54 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -13
- package/dist/streaming.d.ts +17 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +303 -0
- package/dist/web.d.ts +17 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +70 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +121 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
- package/package.json +18 -6
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { validateStreamRunHandle } from "@independo/inderun-contracts";
|
|
2
|
+
import { createBridgeError, normalizePluginError } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Listener event names. These are **public contract**: an app may attach its own
|
|
5
|
+
* listener, and native code emits exactly these. Renaming one is a breaking change.
|
|
6
|
+
*/
|
|
7
|
+
export const STREAM_EVENT_NAME = "indeRunStreamEvent";
|
|
8
|
+
export const STREAM_ERROR_NAME = "indeRunStreamError";
|
|
9
|
+
/**
|
|
10
|
+
* How long to wait, after the terminal event has arrived, for a still-missing
|
|
11
|
+
* earlier event before declaring the run's event sequence irrecoverable.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately a module constant rather than a public option: any value here is a
|
|
14
|
+
* behavioural policy, and behaviour belongs in the engines, not in the bridge.
|
|
15
|
+
*/
|
|
16
|
+
const REORDER_GRACE_MS = 250;
|
|
17
|
+
function createStreamId() {
|
|
18
|
+
const globalCrypto = globalThis.crypto;
|
|
19
|
+
if (typeof globalCrypto?.randomUUID === "function") {
|
|
20
|
+
return globalCrypto.randomUUID();
|
|
21
|
+
}
|
|
22
|
+
return `stream_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 11)}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reassembles one run's canonical event sequence on the JS side of the bridge.
|
|
26
|
+
*
|
|
27
|
+
* The bridge hop is not order-preserving, which is why `StreamEvent.sequence` is
|
|
28
|
+
* the contract's ordering authority rather than arrival order. This sink buffers
|
|
29
|
+
* out-of-order arrivals and yields strictly by `sequence`, so a consumer sees
|
|
30
|
+
* exactly what the engine's Event Gate admitted, in the order it admitted it.
|
|
31
|
+
*
|
|
32
|
+
* It owns no run semantics: it never synthesizes a terminal event, never retries,
|
|
33
|
+
* never decides an outcome. A gap that never closes becomes a transport error, not
|
|
34
|
+
* an invented ending. Everything about *what* a run does stays in the engines.
|
|
35
|
+
*/
|
|
36
|
+
class StreamSink {
|
|
37
|
+
nextSequence = 0;
|
|
38
|
+
pending = new Map();
|
|
39
|
+
ready = [];
|
|
40
|
+
terminalSequence = null;
|
|
41
|
+
runId = null;
|
|
42
|
+
failure = null;
|
|
43
|
+
done = false;
|
|
44
|
+
wake = null;
|
|
45
|
+
graceTimer = null;
|
|
46
|
+
bindRunId(runId) {
|
|
47
|
+
this.runId = runId;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* True once the run has ended — the terminal event has been admitted, or the
|
|
51
|
+
* transport has failed — regardless of whether the consumer has drained it yet.
|
|
52
|
+
*/
|
|
53
|
+
isFinished() {
|
|
54
|
+
return this.done || this.failure !== null;
|
|
55
|
+
}
|
|
56
|
+
accept(event) {
|
|
57
|
+
if (this.isFinished()) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Defence in depth on top of streamId routing: an event for another run is
|
|
61
|
+
// never this run's business.
|
|
62
|
+
if (this.runId !== null && event.runId !== this.runId) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Already delivered (a duplicate), or arriving after the terminal that closed
|
|
66
|
+
// the run. The Event Gate guarantees the terminal is the highest sequence.
|
|
67
|
+
if (event.sequence < this.nextSequence) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (this.terminalSequence !== null && event.sequence > this.terminalSequence) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.pending.set(event.sequence, event);
|
|
74
|
+
if (event.type === "terminal") {
|
|
75
|
+
this.terminalSequence = event.sequence;
|
|
76
|
+
}
|
|
77
|
+
this.drain();
|
|
78
|
+
}
|
|
79
|
+
fail(error) {
|
|
80
|
+
if (this.isFinished()) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.clearGrace();
|
|
84
|
+
this.failure = error;
|
|
85
|
+
this.signal();
|
|
86
|
+
}
|
|
87
|
+
async *iterate() {
|
|
88
|
+
for (;;) {
|
|
89
|
+
while (this.ready.length > 0) {
|
|
90
|
+
yield this.ready.shift();
|
|
91
|
+
}
|
|
92
|
+
if (this.failure !== null) {
|
|
93
|
+
throw this.failure;
|
|
94
|
+
}
|
|
95
|
+
if (this.done) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await new Promise((resolve) => {
|
|
99
|
+
this.wake = resolve;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
dispose() {
|
|
104
|
+
this.clearGrace();
|
|
105
|
+
}
|
|
106
|
+
drain() {
|
|
107
|
+
let moved = false;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const next = this.pending.get(this.nextSequence);
|
|
110
|
+
if (next === undefined) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
this.pending.delete(this.nextSequence);
|
|
114
|
+
this.ready.push(next);
|
|
115
|
+
this.nextSequence += 1;
|
|
116
|
+
moved = true;
|
|
117
|
+
}
|
|
118
|
+
if (this.terminalSequence !== null) {
|
|
119
|
+
if (this.nextSequence > this.terminalSequence) {
|
|
120
|
+
// The whole 0..terminal range has been handed over, in order.
|
|
121
|
+
this.clearGrace();
|
|
122
|
+
this.done = true;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.startGrace();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (moved || this.done) {
|
|
129
|
+
this.signal();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
startGrace() {
|
|
133
|
+
if (this.graceTimer !== null) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.graceTimer = setTimeout(() => {
|
|
137
|
+
this.graceTimer = null;
|
|
138
|
+
this.onGraceExpired();
|
|
139
|
+
}, REORDER_GRACE_MS);
|
|
140
|
+
}
|
|
141
|
+
clearGrace() {
|
|
142
|
+
if (this.graceTimer !== null) {
|
|
143
|
+
clearTimeout(this.graceTimer);
|
|
144
|
+
this.graceTimer = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The terminal arrived but an earlier event never did.
|
|
149
|
+
*
|
|
150
|
+
* Hand over the content that did arrive, in order, then fail. The terminal itself
|
|
151
|
+
* is deliberately withheld: delivering it would assert an ending — a `finalText`,
|
|
152
|
+
* a clean `completed` — that the consumer's own event sequence does not support.
|
|
153
|
+
* A transport that lost events reports that, rather than papering over it.
|
|
154
|
+
*/
|
|
155
|
+
onGraceExpired() {
|
|
156
|
+
if (this.isFinished()) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const terminalSequence = this.terminalSequence;
|
|
160
|
+
let lost = 0;
|
|
161
|
+
for (let sequence = this.nextSequence; sequence <= terminalSequence; sequence += 1) {
|
|
162
|
+
if (!this.pending.has(sequence)) {
|
|
163
|
+
lost += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const [sequence, event] of [...this.pending.entries()].sort(([a], [b]) => a - b)) {
|
|
167
|
+
if (sequence !== terminalSequence) {
|
|
168
|
+
this.ready.push(event);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
this.pending.clear();
|
|
172
|
+
this.failure = createBridgeError(`Capacitor bridge lost ${lost} stream event(s) for run ${this.runId ?? "<unknown>"}.`, this.runId ?? undefined);
|
|
173
|
+
this.signal();
|
|
174
|
+
}
|
|
175
|
+
signal() {
|
|
176
|
+
const wake = this.wake;
|
|
177
|
+
this.wake = null;
|
|
178
|
+
wake?.();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Owns the two plugin listeners and fans notifications out to the sink for each
|
|
183
|
+
* live run.
|
|
184
|
+
*
|
|
185
|
+
* One registration serves every concurrent run: listeners are attached on the
|
|
186
|
+
* first bind and removed once the last run releases, so a consumer that streams
|
|
187
|
+
* repeatedly does not accumulate listeners.
|
|
188
|
+
*/
|
|
189
|
+
class StreamDispatcher {
|
|
190
|
+
sinks = new Map();
|
|
191
|
+
handles = null;
|
|
192
|
+
registering = null;
|
|
193
|
+
async bind(plugin, streamId, sink) {
|
|
194
|
+
this.sinks.set(streamId, sink);
|
|
195
|
+
try {
|
|
196
|
+
await this.ensureListeners(plugin);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
this.sinks.delete(streamId);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
release(streamId) {
|
|
204
|
+
const sink = this.sinks.get(streamId);
|
|
205
|
+
if (sink === undefined) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
sink.dispose();
|
|
209
|
+
this.sinks.delete(streamId);
|
|
210
|
+
if (this.sinks.size === 0) {
|
|
211
|
+
void this.removeListeners();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
ensureListeners(plugin) {
|
|
215
|
+
this.registering ??= (async () => {
|
|
216
|
+
const [events, errors] = await Promise.all([
|
|
217
|
+
plugin.addListener(STREAM_EVENT_NAME, this.handleEvent),
|
|
218
|
+
plugin.addListener(STREAM_ERROR_NAME, this.handleError)
|
|
219
|
+
]);
|
|
220
|
+
this.handles = [events, errors];
|
|
221
|
+
})().catch((error) => {
|
|
222
|
+
this.registering = null;
|
|
223
|
+
throw error;
|
|
224
|
+
});
|
|
225
|
+
return this.registering;
|
|
226
|
+
}
|
|
227
|
+
async removeListeners() {
|
|
228
|
+
const handles = this.handles;
|
|
229
|
+
this.handles = null;
|
|
230
|
+
this.registering = null;
|
|
231
|
+
if (handles === null) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await Promise.all(handles.map((handle) => handle.remove()));
|
|
235
|
+
}
|
|
236
|
+
// Arrow properties: they are handed to addListener and must keep their binding.
|
|
237
|
+
handleEvent = (notification) => {
|
|
238
|
+
// An unknown streamId is a retained notification replayed for a run that has
|
|
239
|
+
// already finished. Dropping it is correct, not an error.
|
|
240
|
+
this.sinks.get(notification.streamId)?.accept(notification.event);
|
|
241
|
+
};
|
|
242
|
+
handleError = (notification) => {
|
|
243
|
+
this.sinks.get(notification.streamId)?.fail(notification.error);
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const dispatcher = new StreamDispatcher();
|
|
247
|
+
/**
|
|
248
|
+
* Starts a Mode 2 run over the bridge and reassembles it into the same `StreamRun`
|
|
249
|
+
* shape `@independo/inderun-web` returns directly.
|
|
250
|
+
*
|
|
251
|
+
* The listeners are registered *before* `startStream` is called, so no event can be
|
|
252
|
+
* emitted for a run whose sink does not exist yet.
|
|
253
|
+
*/
|
|
254
|
+
export async function startCapacitorStream(plugin, request) {
|
|
255
|
+
const streamId = createStreamId();
|
|
256
|
+
const sink = new StreamSink();
|
|
257
|
+
await dispatcher.bind(plugin, streamId, sink);
|
|
258
|
+
let handle;
|
|
259
|
+
try {
|
|
260
|
+
handle = await plugin.startStream({ streamId, request });
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
dispatcher.release(streamId);
|
|
264
|
+
throw normalizePluginError(error);
|
|
265
|
+
}
|
|
266
|
+
if (!validateStreamRunHandle(handle)) {
|
|
267
|
+
dispatcher.release(streamId);
|
|
268
|
+
throw createBridgeError("Capacitor bridge received a malformed StreamRunHandle from the native plugin.");
|
|
269
|
+
}
|
|
270
|
+
sink.bindRunId(handle.runId);
|
|
271
|
+
let cancelled = false;
|
|
272
|
+
const requestCancel = (reason) => {
|
|
273
|
+
// Cancelling a run that has already ended is a no-op by contract, and is
|
|
274
|
+
// answered locally so the bridge is not crossed for nothing.
|
|
275
|
+
if (cancelled || sink.isFinished()) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
cancelled = true;
|
|
279
|
+
void plugin
|
|
280
|
+
.cancelStream({ streamId, ...(reason !== undefined ? { reason } : {}) })
|
|
281
|
+
.catch((error) => sink.fail(normalizePluginError(error)));
|
|
282
|
+
};
|
|
283
|
+
const generator = (async function* consume() {
|
|
284
|
+
try {
|
|
285
|
+
yield* sink.iterate();
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
// Covers the consumer abandoning the loop with `break`: tell native to stop
|
|
289
|
+
// producing, and let go of the listeners.
|
|
290
|
+
requestCancel();
|
|
291
|
+
dispatcher.release(streamId);
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
return {
|
|
295
|
+
handle,
|
|
296
|
+
events: {
|
|
297
|
+
// Single-use, matching the engines: the run is driven once, not restarted per
|
|
298
|
+
// consumer, so a second iteration observes completion rather than a replay.
|
|
299
|
+
[Symbol.asyncIterator]: () => generator
|
|
300
|
+
},
|
|
301
|
+
cancel: requestCancel
|
|
302
|
+
};
|
|
303
|
+
}
|
package/dist/web.d.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import type { TaskResult } from "@independo/inderun-contracts";
|
|
1
|
+
import type { StreamRunHandle, TaskResult } from "@independo/inderun-contracts";
|
|
2
2
|
import { WebPlugin } from "@capacitor/core";
|
|
3
|
-
import type { ConfigureOptions, IndeRunCapacitorPlugin } from "./definitions.js";
|
|
3
|
+
import type { CancelStreamOptions, ConfigureOptions, IndeRunCapacitorPlugin, StartStreamOptions } from "./definitions.js";
|
|
4
4
|
import type { TaskRequest } from "@independo/inderun-contracts";
|
|
5
5
|
export declare class IndeRunWeb extends WebPlugin implements IndeRunCapacitorPlugin {
|
|
6
6
|
private engine;
|
|
7
|
+
private readonly activeStreams;
|
|
8
|
+
private readonly startingStreams;
|
|
9
|
+
private readonly pendingCancels;
|
|
7
10
|
configure(options?: ConfigureOptions): Promise<void>;
|
|
8
11
|
run(request: TaskRequest): Promise<TaskResult>;
|
|
12
|
+
/**
|
|
13
|
+
* The web path deliberately round-trips through `notifyListeners` rather than
|
|
14
|
+
* handing the engine's `StreamRun` straight back.
|
|
15
|
+
*
|
|
16
|
+
* Short-circuiting would require the facade to branch on the platform and would
|
|
17
|
+
* leave two reassembly paths whose equivalence is only a convention — while this
|
|
18
|
+
* way there is exactly one, exercised on every platform. It also equalizes the
|
|
19
|
+
* loss of generator backpressure that the native paths have regardless.
|
|
20
|
+
*/
|
|
21
|
+
startStream(options: StartStreamOptions): Promise<StreamRunHandle>;
|
|
22
|
+
cancelStream(options: CancelStreamOptions): Promise<void>;
|
|
23
|
+
private pump;
|
|
9
24
|
}
|
|
10
25
|
//# sourceMappingURL=web.d.ts.map
|
package/dist/web.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAChF,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,EACV,mBAAmB,EACnB,gBAAgB,EAChB,sBAAsB,EACtB,kBAAkB,EAEnB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAIhE,qBAAa,UAAW,SAAQ,SAAU,YAAW,sBAAsB;IACzE,OAAO,CAAC,MAAM,CAAwB;IAItC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgC;IAC9D,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAyC;IAElE,SAAS,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6BzD;IAEK,GAAG,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAYnD;IAED;;;;;;;;OAQG;IACG,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,eAAe,CAAC,CA8BvE;IAEK,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiB9D;YAEa,IAAI;CAenB"}
|
package/dist/web.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { WebPlugin } from "@capacitor/core";
|
|
2
2
|
import { createIndeRunWeb, createUnavailable, toIndeRunException } from "@independo/inderun-web";
|
|
3
|
+
import { STREAM_ERROR_NAME, STREAM_EVENT_NAME } from "./streaming.js";
|
|
3
4
|
export class IndeRunWeb extends WebPlugin {
|
|
4
5
|
engine = null;
|
|
6
|
+
// Deliberately the bridge's StreamRun, not the web SDK's: the engine's run is
|
|
7
|
+
// assigned straight into this map, so the two shapes staying identical is checked
|
|
8
|
+
// by the compiler rather than by convention.
|
|
9
|
+
activeStreams = new Map();
|
|
10
|
+
startingStreams = new Set();
|
|
11
|
+
pendingCancels = new Map();
|
|
5
12
|
async configure(options) {
|
|
6
13
|
if (!options?.openAI) {
|
|
7
14
|
throw createUnavailable("Capacitor web execution requires OpenAI provider registration. Configure with openAI bootstrap options before calling run(request).").toContractError();
|
|
@@ -30,6 +37,69 @@ export class IndeRunWeb extends WebPlugin {
|
|
|
30
37
|
throw toIndeRunException(error).toContractError();
|
|
31
38
|
}
|
|
32
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The web path deliberately round-trips through `notifyListeners` rather than
|
|
42
|
+
* handing the engine's `StreamRun` straight back.
|
|
43
|
+
*
|
|
44
|
+
* Short-circuiting would require the facade to branch on the platform and would
|
|
45
|
+
* leave two reassembly paths whose equivalence is only a convention — while this
|
|
46
|
+
* way there is exactly one, exercised on every platform. It also equalizes the
|
|
47
|
+
* loss of generator backpressure that the native paths have regardless.
|
|
48
|
+
*/
|
|
49
|
+
async startStream(options) {
|
|
50
|
+
if (!this.engine) {
|
|
51
|
+
throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling stream(request).").toContractError();
|
|
52
|
+
}
|
|
53
|
+
this.startingStreams.add(options.streamId);
|
|
54
|
+
let run;
|
|
55
|
+
try {
|
|
56
|
+
run = await this.engine.stream(options.request);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
this.startingStreams.delete(options.streamId);
|
|
60
|
+
this.pendingCancels.delete(options.streamId);
|
|
61
|
+
throw toIndeRunException(error).toContractError();
|
|
62
|
+
}
|
|
63
|
+
this.startingStreams.delete(options.streamId);
|
|
64
|
+
this.activeStreams.set(options.streamId, run);
|
|
65
|
+
if (this.pendingCancels.has(options.streamId)) {
|
|
66
|
+
const reason = this.pendingCancels.get(options.streamId);
|
|
67
|
+
this.pendingCancels.delete(options.streamId);
|
|
68
|
+
run.cancel(reason);
|
|
69
|
+
}
|
|
70
|
+
// Pump on a later microtask so this call's handle settles first.
|
|
71
|
+
queueMicrotask(() => void this.pump(options.streamId, run));
|
|
72
|
+
return run.handle;
|
|
73
|
+
}
|
|
74
|
+
async cancelStream(options) {
|
|
75
|
+
const run = this.activeStreams.get(options.streamId);
|
|
76
|
+
if (run !== undefined) {
|
|
77
|
+
// Never abandon the generator: the engine must stay free to emit its one
|
|
78
|
+
// `cancelled` terminal event.
|
|
79
|
+
run.cancel(options.reason);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.startingStreams.has(options.streamId)) {
|
|
83
|
+
// Cancel raced startStream's route selection; applied once the run attaches.
|
|
84
|
+
this.pendingCancels.set(options.streamId, options.reason);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Unknown or already-finished run: cancelling after the terminal is a no-op,
|
|
88
|
+
// not an error.
|
|
89
|
+
}
|
|
90
|
+
async pump(streamId, run) {
|
|
91
|
+
try {
|
|
92
|
+
for await (const event of run.events) {
|
|
93
|
+
this.notifyListeners(STREAM_EVENT_NAME, { streamId, event }, true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
this.notifyListeners(STREAM_ERROR_NAME, { streamId, error: toIndeRunException(error).toContractError() }, true);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.activeStreams.delete(streamId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
33
103
|
}
|
|
34
104
|
function compactOpenAIOptions(options) {
|
|
35
105
|
const openAI = options.openAI;
|
|
@@ -36,9 +36,33 @@ struct CapacitorRunOptions: Codable {
|
|
|
36
36
|
let allowDirectOpenAIEndpoint: Bool? // web-only, no-op on native
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/// Unlike `run(request)`, which takes the request at the options root, `startStream`
|
|
40
|
+
/// nests it under `request` so the envelope can also carry the bridge-local
|
|
41
|
+
/// `streamId`. See `StartStreamOptions` in src/definitions.ts.
|
|
42
|
+
struct CapacitorStartStreamOptions: Codable {
|
|
43
|
+
let streamId: String
|
|
44
|
+
let request: TaskRequest
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
struct CapacitorCancelStreamOptions: Codable {
|
|
48
|
+
let streamId: String
|
|
49
|
+
let reason: String?
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Called with an already-encoded event/error for one run. The plugin turns these
|
|
53
|
+
/// into `notifyListeners` calls; taking them as closures keeps the pump testable
|
|
54
|
+
/// without Capacitor.
|
|
55
|
+
typealias StreamEventSink = @Sendable (String, JSObject) -> Void
|
|
56
|
+
typealias StreamErrorSink = @Sendable (String, JSObject) -> Void
|
|
57
|
+
|
|
39
58
|
final class IndeRunCapacitorBridge {
|
|
40
59
|
private var configuredRegistry: ProviderRegistry?
|
|
41
60
|
private var configuredHostServices: HostServices?
|
|
61
|
+
let streams = StreamRegistry()
|
|
62
|
+
|
|
63
|
+
deinit {
|
|
64
|
+
streams.cancelAll(reason: "Capacitor bridge deallocated.")
|
|
65
|
+
}
|
|
42
66
|
|
|
43
67
|
func configure(options: JSObject) throws {
|
|
44
68
|
let runOptions = try decodeConfigureOptions(from: options)
|
|
@@ -58,10 +82,97 @@ final class IndeRunCapacitorBridge {
|
|
|
58
82
|
return try encode(result)
|
|
59
83
|
}
|
|
60
84
|
|
|
85
|
+
func startStream(
|
|
86
|
+
options: JSObject,
|
|
87
|
+
onEvent: @escaping StreamEventSink,
|
|
88
|
+
onError: @escaping StreamErrorSink
|
|
89
|
+
) async throws -> JSObject {
|
|
90
|
+
guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
|
|
91
|
+
throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling stream(request).")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let start = try decodeStartStreamOptions(from: options)
|
|
95
|
+
// Reserved before the engine is reached, so a cancel arriving during route
|
|
96
|
+
// selection is recorded rather than dropped as an unknown id.
|
|
97
|
+
streams.open(streamId: start.streamId)
|
|
98
|
+
|
|
99
|
+
let run: StreamRun
|
|
100
|
+
do {
|
|
101
|
+
// IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
|
|
102
|
+
let engine = IndeRun(registry: registry, hostServices: hostServices)
|
|
103
|
+
run = try await engine.stream(request: start.request)
|
|
104
|
+
} catch {
|
|
105
|
+
streams.finish(streamId: start.streamId)
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if case .cancelRequested(let reason) = streams.attach(streamId: start.streamId, run: run) {
|
|
110
|
+
run.cancel(reason: reason)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let streamId = start.streamId
|
|
114
|
+
// Weak, so a live run cannot keep the bridge alive past the plugin's deinit;
|
|
115
|
+
// the plugin's teardown cancels these tasks, which then release the bridge.
|
|
116
|
+
let task = Task { [weak self] in
|
|
117
|
+
guard let self else { return }
|
|
118
|
+
await self.pump(streamId: streamId, run: run, onEvent: onEvent, onError: onError)
|
|
119
|
+
}
|
|
120
|
+
streams.store(task: task, for: streamId)
|
|
121
|
+
|
|
122
|
+
return try encode(handle: run.handle)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func cancelStream(options: JSObject) throws {
|
|
126
|
+
let cancel = try decodeCancelStreamOptions(from: options)
|
|
127
|
+
streams.requestCancel(streamId: cancel.streamId, reason: cancel.reason)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func teardownStreams() {
|
|
131
|
+
streams.cancelAll(reason: "Capacitor plugin torn down.")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Forwards one run's canonical events to the sink until the stream ends.
|
|
135
|
+
///
|
|
136
|
+
/// A provider failure has already become a terminal `error` event by the time it
|
|
137
|
+
/// reaches here — the engine's Event Gate owns that. Anything thrown out of the
|
|
138
|
+
/// sequence is a failure of this bridge, and is reported as one.
|
|
139
|
+
func pump(
|
|
140
|
+
streamId: String,
|
|
141
|
+
run: StreamRun,
|
|
142
|
+
onEvent: @escaping StreamEventSink,
|
|
143
|
+
onError: @escaping StreamErrorSink
|
|
144
|
+
) async {
|
|
145
|
+
do {
|
|
146
|
+
for try await event in run.events {
|
|
147
|
+
onEvent(streamId, try encode(streamEvent: event))
|
|
148
|
+
}
|
|
149
|
+
} catch is CancellationError {
|
|
150
|
+
// Our own task cancellation, from teardown. The webview is going away and
|
|
151
|
+
// no one is left to receive a terminal event; not a bridge fault.
|
|
152
|
+
} catch {
|
|
153
|
+
let contractError = toIndeRunException(error).toContractError()
|
|
154
|
+
if let encoded = try? encode(error: contractError) {
|
|
155
|
+
onError(streamId, encoded)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
streams.finish(streamId: streamId)
|
|
159
|
+
}
|
|
160
|
+
|
|
61
161
|
func encode(error: IndeRunError) throws -> JSObject {
|
|
62
162
|
try encodeObject(error)
|
|
63
163
|
}
|
|
64
164
|
|
|
165
|
+
// JSONEncoder omits nil optionals, so "emit only the fields this event actually
|
|
166
|
+
// has" — which is what reconstitutes the right union branch on the JS side —
|
|
167
|
+
// comes for free from the generated Codable conformances.
|
|
168
|
+
func encode(streamEvent: StreamEvent) throws -> JSObject {
|
|
169
|
+
try encodeObject(streamEvent)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func encode(handle: StreamRunHandle) throws -> JSObject {
|
|
173
|
+
try encodeObject(handle)
|
|
174
|
+
}
|
|
175
|
+
|
|
65
176
|
private func makeRegistry(openAI: OpenAIProviderBootstrapOptions?) throws -> ProviderRegistry {
|
|
66
177
|
let registry = ProviderRegistry()
|
|
67
178
|
try registry.register(AppleFoundationModelsProvider())
|
|
@@ -89,6 +200,16 @@ final class IndeRunCapacitorBridge {
|
|
|
89
200
|
return try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
|
|
90
201
|
}
|
|
91
202
|
|
|
203
|
+
private func decodeStartStreamOptions(from object: JSObject) throws -> CapacitorStartStreamOptions {
|
|
204
|
+
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
205
|
+
return try JSONDecoder().decode(CapacitorStartStreamOptions.self, from: data)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private func decodeCancelStreamOptions(from object: JSObject) throws -> CapacitorCancelStreamOptions {
|
|
209
|
+
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
210
|
+
return try JSONDecoder().decode(CapacitorCancelStreamOptions.self, from: data)
|
|
211
|
+
}
|
|
212
|
+
|
|
92
213
|
private func decodeRequest(from object: JSObject) throws -> TaskRequest {
|
|
93
214
|
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
|
94
215
|
return try JSONDecoder().decode(TaskRequest.self, from: data)
|
|
@@ -9,11 +9,20 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
9
9
|
public let jsName = "IndeRunCapacitor"
|
|
10
10
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
11
11
|
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
|
12
|
-
CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise)
|
|
12
|
+
CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "startStream", returnType: CAPPluginReturnPromise),
|
|
14
|
+
CAPPluginMethod(name: "cancelStream", returnType: CAPPluginReturnPromise)
|
|
13
15
|
]
|
|
14
16
|
|
|
15
17
|
private let implementation = IndeRunCapacitorBridge()
|
|
16
18
|
|
|
19
|
+
/// CAPPlugin has no teardown hook — `load()` has no counterpart — so deinit is
|
|
20
|
+
/// the only place left to stop runs the webview can no longer receive. The pump
|
|
21
|
+
/// tasks hold `self` weakly precisely so this can run.
|
|
22
|
+
deinit {
|
|
23
|
+
implementation.teardownStreams()
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
@objc func configure(_ call: CAPPluginCall) {
|
|
18
27
|
do {
|
|
19
28
|
try implementation.configure(options: call.options)
|
|
@@ -47,5 +56,65 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
}
|
|
59
|
+
|
|
60
|
+
/// Resolves with the run handle. Only validation and route-selection failures
|
|
61
|
+
/// reject here; a provider failure, a cancellation, or completion all arrive as
|
|
62
|
+
/// the single terminal event on `indeRunStreamEvent`.
|
|
63
|
+
///
|
|
64
|
+
/// `retainUntilConsumed` closes the listener-registration race from the native
|
|
65
|
+
/// side: an event emitted before the JS listener is attached is retained and
|
|
66
|
+
/// replayed rather than lost.
|
|
67
|
+
@objc func startStream(_ call: CAPPluginCall) {
|
|
68
|
+
Task { [weak self] in
|
|
69
|
+
guard let self else { return }
|
|
70
|
+
do {
|
|
71
|
+
let handle = try await self.implementation.startStream(
|
|
72
|
+
options: call.options,
|
|
73
|
+
onEvent: { [weak self] streamId, event in
|
|
74
|
+
self?.notifyListeners(
|
|
75
|
+
"indeRunStreamEvent",
|
|
76
|
+
data: ["streamId": streamId, "event": event],
|
|
77
|
+
retainUntilConsumed: true
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
onError: { [weak self] streamId, error in
|
|
81
|
+
self?.notifyListeners(
|
|
82
|
+
"indeRunStreamError",
|
|
83
|
+
data: ["streamId": streamId, "error": error],
|
|
84
|
+
retainUntilConsumed: true
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
call.resolve(handle)
|
|
89
|
+
} catch let error as IndeRunException {
|
|
90
|
+
let contractError = error.toContractError()
|
|
91
|
+
let details = try? self.implementation.encode(error: contractError)
|
|
92
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
|
|
93
|
+
} catch {
|
|
94
|
+
let normalized = toIndeRunException(error)
|
|
95
|
+
let contractError = normalized.toContractError()
|
|
96
|
+
let details = try? self.implementation.encode(error: contractError)
|
|
97
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Resolves for an unknown or already-finished run: cancelling after the terminal
|
|
103
|
+
/// is a no-op by contract, not an error.
|
|
104
|
+
@objc func cancelStream(_ call: CAPPluginCall) {
|
|
105
|
+
do {
|
|
106
|
+
try implementation.cancelStream(options: call.options)
|
|
107
|
+
call.resolve()
|
|
108
|
+
} catch let error as IndeRunException {
|
|
109
|
+
let contractError = error.toContractError()
|
|
110
|
+
let details = try? implementation.encode(error: contractError)
|
|
111
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
|
|
112
|
+
} catch {
|
|
113
|
+
let normalized = toIndeRunException(error)
|
|
114
|
+
let contractError = normalized.toContractError()
|
|
115
|
+
let details = try? implementation.encode(error: contractError)
|
|
116
|
+
call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
50
119
|
}
|
|
51
120
|
#endif
|