@independo/capacitor-inderun 1.0.0 → 1.1.0-dev.1

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 (30) hide show
  1. package/Package.swift +5 -1
  2. package/README.md +79 -5
  3. package/android/build.gradle.kts +5 -5
  4. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  7. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  9. package/dist/definitions.d.ts +54 -1
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +36 -0
  14. package/dist/index.d.ts +14 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +30 -13
  17. package/dist/streaming.d.ts +17 -0
  18. package/dist/streaming.d.ts.map +1 -0
  19. package/dist/streaming.js +303 -0
  20. package/dist/web.d.ts +17 -2
  21. package/dist/web.d.ts.map +1 -1
  22. package/dist/web.js +70 -0
  23. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +134 -0
  24. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
  25. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  26. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +31 -1
  27. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  28. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  29. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  30. package/package.json +3 -3
@@ -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;AAC/D,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AACjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAGhE,qBAAa,UAAW,SAAQ,SAAU,YAAW,sBAAsB;IACzE,OAAO,CAAC,MAAM,CAAwB;IAEhC,SAAS,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6BzD;IAEK,GAAG,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAYnD;CACF"}
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;
@@ -16,6 +16,19 @@ struct OpenAIProviderBootstrapOptions: Codable {
16
16
  let auth: String?
17
17
  let authContextRef: String?
18
18
  let timeoutMs: Int?
19
+
20
+ // The property keeps Swift's `URL` capitalisation to match OpenAIProviderOptions,
21
+ // but the wire key is whatever `ConfigureOptions` in src/definitions.ts sends --
22
+ // `endpointUrl`, which is also what IndeRunSerializer.kt reads on Android. Without
23
+ // this mapping the field silently decodes as nil and the provider falls back to the
24
+ // default endpoint.
25
+ enum CodingKeys: String, CodingKey {
26
+ case model
27
+ case endpointURL = "endpointUrl"
28
+ case auth
29
+ case authContextRef
30
+ case timeoutMs
31
+ }
19
32
  }
20
33
 
21
34
  struct CapacitorRunOptions: Codable {
@@ -23,9 +36,33 @@ struct CapacitorRunOptions: Codable {
23
36
  let allowDirectOpenAIEndpoint: Bool? // web-only, no-op on native
24
37
  }
25
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
+
26
58
  final class IndeRunCapacitorBridge {
27
59
  private var configuredRegistry: ProviderRegistry?
28
60
  private var configuredHostServices: HostServices?
61
+ let streams = StreamRegistry()
62
+
63
+ deinit {
64
+ streams.cancelAll(reason: "Capacitor bridge deallocated.")
65
+ }
29
66
 
30
67
  func configure(options: JSObject) throws {
31
68
  let runOptions = try decodeConfigureOptions(from: options)
@@ -45,10 +82,97 @@ final class IndeRunCapacitorBridge {
45
82
  return try encode(result)
46
83
  }
47
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
+
48
161
  func encode(error: IndeRunError) throws -> JSObject {
49
162
  try encodeObject(error)
50
163
  }
51
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
+
52
176
  private func makeRegistry(openAI: OpenAIProviderBootstrapOptions?) throws -> ProviderRegistry {
53
177
  let registry = ProviderRegistry()
54
178
  try registry.register(AppleFoundationModelsProvider())
@@ -76,6 +200,16 @@ final class IndeRunCapacitorBridge {
76
200
  return try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
77
201
  }
78
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
+
79
213
  private func decodeRequest(from object: JSObject) throws -> TaskRequest {
80
214
  let data = try JSONSerialization.data(withJSONObject: object, options: [])
81
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