@xybrid/react-native 0.10.0-rc1

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 (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +298 -0
  3. package/android/build.gradle +61 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/ai/xybrid/reactnative/XybridCodec.kt +481 -0
  6. package/android/src/main/java/ai/xybrid/reactnative/XybridHandles.kt +139 -0
  7. package/android/src/main/java/ai/xybrid/reactnative/XybridModule.kt +665 -0
  8. package/android/src/main/java/ai/xybrid/reactnative/XybridPackage.kt +27 -0
  9. package/ios/XybridCodec.swift +508 -0
  10. package/ios/XybridHandles.swift +191 -0
  11. package/ios/XybridModule.mm +494 -0
  12. package/ios/XybridModuleImpl.swift +887 -0
  13. package/ios/XybridSwift/Xybrid.swift +1401 -0
  14. package/ios/XybridSwift/xybrid_bolt.swift +3198 -0
  15. package/ios/xybrid_natives.rb +178 -0
  16. package/lib/commonjs/NativeXybrid.js +24 -0
  17. package/lib/commonjs/base64.js +45 -0
  18. package/lib/commonjs/cancel.js +35 -0
  19. package/lib/commonjs/context.js +67 -0
  20. package/lib/commonjs/envelope.js +79 -0
  21. package/lib/commonjs/errors.js +61 -0
  22. package/lib/commonjs/index.js +59 -0
  23. package/lib/commonjs/model.js +307 -0
  24. package/lib/commonjs/pipeline.js +55 -0
  25. package/lib/commonjs/presets.js +32 -0
  26. package/lib/commonjs/session.js +76 -0
  27. package/lib/commonjs/types.js +10 -0
  28. package/lib/commonjs/wire.js +285 -0
  29. package/lib/commonjs/xybrid.js +133 -0
  30. package/lib/module/NativeXybrid.js +22 -0
  31. package/lib/module/base64.js +41 -0
  32. package/lib/module/cancel.js +29 -0
  33. package/lib/module/context.js +60 -0
  34. package/lib/module/envelope.js +73 -0
  35. package/lib/module/errors.js +56 -0
  36. package/lib/module/index.js +36 -0
  37. package/lib/module/model.js +298 -0
  38. package/lib/module/pipeline.js +48 -0
  39. package/lib/module/presets.js +27 -0
  40. package/lib/module/session.js +69 -0
  41. package/lib/module/types.js +9 -0
  42. package/lib/module/wire.js +271 -0
  43. package/lib/module/xybrid.js +127 -0
  44. package/lib/typescript/NativeXybrid.d.ts +68 -0
  45. package/lib/typescript/NativeXybrid.d.ts.map +1 -0
  46. package/lib/typescript/base64.d.ts +5 -0
  47. package/lib/typescript/base64.d.ts.map +1 -0
  48. package/lib/typescript/cancel.d.ts +24 -0
  49. package/lib/typescript/cancel.d.ts.map +1 -0
  50. package/lib/typescript/context.d.ts +40 -0
  51. package/lib/typescript/context.d.ts.map +1 -0
  52. package/lib/typescript/envelope.d.ts +51 -0
  53. package/lib/typescript/envelope.d.ts.map +1 -0
  54. package/lib/typescript/errors.d.ts +12 -0
  55. package/lib/typescript/errors.d.ts.map +1 -0
  56. package/lib/typescript/index.d.ts +28 -0
  57. package/lib/typescript/index.d.ts.map +1 -0
  58. package/lib/typescript/model.d.ts +211 -0
  59. package/lib/typescript/model.d.ts.map +1 -0
  60. package/lib/typescript/pipeline.d.ts +35 -0
  61. package/lib/typescript/pipeline.d.ts.map +1 -0
  62. package/lib/typescript/presets.d.ts +21 -0
  63. package/lib/typescript/presets.d.ts.map +1 -0
  64. package/lib/typescript/session.d.ts +50 -0
  65. package/lib/typescript/session.d.ts.map +1 -0
  66. package/lib/typescript/types.d.ts +313 -0
  67. package/lib/typescript/types.d.ts.map +1 -0
  68. package/lib/typescript/wire.d.ts +130 -0
  69. package/lib/typescript/wire.d.ts.map +1 -0
  70. package/lib/typescript/xybrid.d.ts +89 -0
  71. package/lib/typescript/xybrid.d.ts.map +1 -0
  72. package/package.json +88 -0
  73. package/react-native-xybrid.podspec +50 -0
  74. package/src/NativeXybrid.ts +131 -0
  75. package/src/base64.ts +43 -0
  76. package/src/cancel.ts +47 -0
  77. package/src/context.ts +70 -0
  78. package/src/envelope.ts +115 -0
  79. package/src/errors.ts +67 -0
  80. package/src/index.ts +91 -0
  81. package/src/model.ts +408 -0
  82. package/src/pipeline.ts +66 -0
  83. package/src/presets.ts +36 -0
  84. package/src/session.ts +76 -0
  85. package/src/types.ts +362 -0
  86. package/src/wire.ts +408 -0
  87. package/src/xybrid.ts +176 -0
@@ -0,0 +1,508 @@
1
+ import Foundation
2
+
3
+ // Translation between the bolt Swift types (xybrid_bolt.swift) and the
4
+ // Foundation values React Native passes across the TurboModule boundary.
5
+ //
6
+ // Pure functions only — no SDK calls, no module state — so
7
+ // tests/swift (scripts/test-ios.sh) can exercise them against the real bolt
8
+ // records on a Mac host. The wire shapes are defined once, in src/wire.ts;
9
+ // every convenience field (voiceId, speed, sampleRate…) was already folded
10
+ // into envelope metadata on the JS side, so envelopes here are just a payload
11
+ // kind plus string metadata, exactly like `XybridEnvelope`.
12
+ //
13
+ // Names avoid every type alias in Xybrid.swift (`Envelope`, `Result`,
14
+ // `Model`, …), which compiles into the same module.
15
+
16
+ /// A JS object as React Native hands it over.
17
+ typealias JSObject = [String: Any]
18
+
19
+ /// A failure raised by the bridge itself rather than the SDK.
20
+ enum BridgeError: Error {
21
+ /// A JS argument could not be decoded. → `xybrid_invalid_argument`
22
+ case invalidArgument(String)
23
+ /// Unknown, released, or wrong-kind handle. → `xybrid_handle`
24
+ case handle(String)
25
+ /// A configuration conflict. → `xybrid_config_error`
26
+ case config(String)
27
+ }
28
+
29
+ enum XybridCodec {
30
+ // MARK: - Reading JS values
31
+
32
+ /// The value for `key`, treating `NSNull` (JS `null`) as absent.
33
+ static func value(_ object: JSObject, _ key: String) -> Any? {
34
+ guard let value = object[key], !(value is NSNull) else { return nil }
35
+ return value
36
+ }
37
+
38
+ static func object(_ any: Any?, _ what: String) throws -> JSObject {
39
+ guard let object = any as? JSObject else {
40
+ throw BridgeError.invalidArgument("\(what) must be an object")
41
+ }
42
+ return object
43
+ }
44
+
45
+ static func optionalObject(_ object: JSObject, _ key: String, _ what: String) throws -> JSObject? {
46
+ guard let raw = value(object, key) else { return nil }
47
+ return try self.object(raw, what)
48
+ }
49
+
50
+ static func string(_ object: JSObject, _ key: String, _ what: String) throws -> String {
51
+ guard let string = value(object, key) as? String else {
52
+ throw BridgeError.invalidArgument("\(what): '\(key)' must be a string")
53
+ }
54
+ return string
55
+ }
56
+
57
+ static func optionalString(_ object: JSObject, _ key: String, _ what: String) throws -> String? {
58
+ guard let raw = value(object, key) else { return nil }
59
+ guard let string = raw as? String else {
60
+ throw BridgeError.invalidArgument("\(what): '\(key)' must be a string")
61
+ }
62
+ return string
63
+ }
64
+
65
+ static func optionalBool(_ object: JSObject, _ key: String, _ what: String) throws -> Bool? {
66
+ guard let raw = value(object, key) else { return nil }
67
+ // JS booleans arrive as NSNumber-backed `Bool`; accept only real booleans.
68
+ guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
69
+ throw BridgeError.invalidArgument("\(what): '\(key)' must be a boolean")
70
+ }
71
+ return number.boolValue
72
+ }
73
+
74
+ static func optionalDouble(_ object: JSObject, _ key: String, _ what: String) throws -> Double? {
75
+ guard let raw = value(object, key) else { return nil }
76
+ guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
77
+ number.doubleValue.isFinite else {
78
+ throw BridgeError.invalidArgument("\(what): '\(key)' must be a finite number")
79
+ }
80
+ return number.doubleValue
81
+ }
82
+
83
+ static func optionalFloat(_ object: JSObject, _ key: String, _ what: String) throws -> Float? {
84
+ try optionalDouble(object, key, what).map { Float($0) }
85
+ }
86
+
87
+ /// A non-negative integer that fits in `UInt32`; fractions truncate.
88
+ static func optionalUInt32(_ object: JSObject, _ key: String, _ what: String) throws -> UInt32? {
89
+ guard let double = try optionalDouble(object, key, what) else { return nil }
90
+ return try uint32(double, "\(what): '\(key)'")
91
+ }
92
+
93
+ static func uint32(_ double: Double, _ what: String) throws -> UInt32 {
94
+ guard double >= 0, double <= Double(UInt32.max) else {
95
+ throw BridgeError.invalidArgument("\(what) must be between 0 and \(UInt32.max)")
96
+ }
97
+ return UInt32(double)
98
+ }
99
+
100
+ static func stringArray(_ object: JSObject, _ key: String, _ what: String) throws -> [String] {
101
+ guard let raw = value(object, key) else { return [] }
102
+ guard let strings = raw as? [String] else {
103
+ throw BridgeError.invalidArgument("\(what): '\(key)' must be an array of strings")
104
+ }
105
+ return strings
106
+ }
107
+
108
+ static func base64(_ object: JSObject, _ key: String, _ what: String) throws -> Data {
109
+ guard let data = decodeBase64(try string(object, key, what)) else {
110
+ throw BridgeError.invalidArgument("\(what): '\(key)' is not valid base64")
111
+ }
112
+ return data
113
+ }
114
+
115
+ /// Strict standard base64 (whitespace ignored), or `nil` — the same rule
116
+ /// the Android codec applies, so garbage fails the same way everywhere.
117
+ static func decodeBase64(_ encoded: String) -> Data? {
118
+ Data(base64Encoded: String(encoded.filter { !$0.isWhitespace }))
119
+ }
120
+
121
+ /// Accept plain paths and `file://` URLs (what Expo's file APIs hand out).
122
+ static func filePath(_ raw: String) -> String {
123
+ guard raw.hasPrefix("file://"), let url = URL(string: raw), url.isFileURL else { return raw }
124
+ return url.path
125
+ }
126
+
127
+ /// Little-endian Float32 PCM from base64.
128
+ static func float32Samples(_ encoded: String) throws -> [Float] {
129
+ guard let data = decodeBase64(encoded) else {
130
+ throw BridgeError.invalidArgument("samples are not valid base64")
131
+ }
132
+ guard data.count % MemoryLayout<Float>.size == 0 else {
133
+ throw BridgeError.invalidArgument("samples must be whole Float32 values (\(data.count) bytes)")
134
+ }
135
+ var samples = [Float](repeating: 0, count: data.count / MemoryLayout<Float>.size)
136
+ _ = samples.withUnsafeMutableBytes { data.copyBytes(to: $0) }
137
+ return samples.map { Float(bitPattern: UInt32(littleEndian: $0.bitPattern)) }
138
+ }
139
+
140
+ // MARK: - Envelopes
141
+
142
+ static func decodeEnvelope(_ any: Any?) throws -> XybridEnvelope {
143
+ let object = try self.object(any, "envelope")
144
+ let kind = try string(object, "kind", "envelope")
145
+ let what = "\(kind) envelope"
146
+ let payload: XybridEnvelopeKind
147
+ switch kind {
148
+ case "text":
149
+ payload = .text(text: try string(object, "text", what))
150
+ case "audio":
151
+ payload = .audio(bytes: try base64(object, "bytesBase64", what))
152
+ case "embedding":
153
+ guard let numbers = value(object, "data") as? [NSNumber] else {
154
+ throw BridgeError.invalidArgument("\(what): 'data' must be an array of numbers")
155
+ }
156
+ payload = .embedding(values: numbers.map { $0.floatValue })
157
+ case "image":
158
+ payload = .image(bytes: try base64(object, "bytesBase64", what),
159
+ format: try string(object, "format", what))
160
+ case "multipart":
161
+ guard let parts = value(object, "parts") as? [Any] else {
162
+ throw BridgeError.invalidArgument("\(what): 'parts' must be an array")
163
+ }
164
+ payload = .multiPart(parts: try parts.map(decodeEnvelope))
165
+ default:
166
+ throw BridgeError.invalidArgument("unknown envelope kind '\(kind)'")
167
+ }
168
+ return XybridEnvelope(kind: payload, metadata: try decodeMetadata(object["metadata"]))
169
+ }
170
+
171
+ static func decodeMetadata(_ any: Any?) throws -> [XybridMetadataEntry] {
172
+ guard let any, !(any is NSNull) else { return [] }
173
+ guard let object = any as? JSObject else {
174
+ throw BridgeError.invalidArgument("envelope metadata must be an object of strings")
175
+ }
176
+ return try object.keys.sorted().map { key in
177
+ guard let value = object[key] as? String else {
178
+ throw BridgeError.invalidArgument("envelope metadata '\(key)' must be a string")
179
+ }
180
+ return XybridMetadataEntry(key: key, value: value)
181
+ }
182
+ }
183
+
184
+ static func encodeEnvelope(_ envelope: XybridEnvelope) -> JSObject {
185
+ var out: JSObject = [
186
+ "metadata": Dictionary(envelope.metadata.map { ($0.key, $0.value) },
187
+ uniquingKeysWith: { _, last in last }),
188
+ ]
189
+ switch envelope.kind {
190
+ case .text(let text):
191
+ out["kind"] = "text"
192
+ out["text"] = text
193
+ case .audio(let bytes):
194
+ out["kind"] = "audio"
195
+ out["bytesBase64"] = bytes.base64EncodedString()
196
+ case .embedding(let values):
197
+ out["kind"] = "embedding"
198
+ out["data"] = values.map { Double($0) }
199
+ case .image(let bytes, let format):
200
+ out["kind"] = "image"
201
+ out["bytesBase64"] = bytes.base64EncodedString()
202
+ out["format"] = format
203
+ case .multiPart(let parts):
204
+ out["kind"] = "multipart"
205
+ out["parts"] = parts.map(encodeEnvelope)
206
+ }
207
+ return out
208
+ }
209
+
210
+ // MARK: - Generation and run options
211
+
212
+ static func decodeToolDefinition(_ any: Any) throws -> XybridToolDefinition {
213
+ let object = try self.object(any, "tool")
214
+ return XybridToolDefinition(
215
+ name: try string(object, "name", "tool"),
216
+ description: try string(object, "description", "tool"),
217
+ parametersJson: try string(object, "parametersJson", "tool")
218
+ )
219
+ }
220
+
221
+ static func decodeGenerationConfig(_ object: JSObject) throws -> XybridGenerationConfig {
222
+ let what = "generationConfig"
223
+ let tools = try (value(object, "tools") as? [Any] ?? []).map(decodeToolDefinition)
224
+ return XybridGenerationConfig(
225
+ maxTokens: try optionalUInt32(object, "maxTokens", what),
226
+ temperature: try optionalFloat(object, "temperature", what),
227
+ topP: try optionalFloat(object, "topP", what),
228
+ minP: try optionalFloat(object, "minP", what),
229
+ topK: try optionalUInt32(object, "topK", what),
230
+ repetitionPenalty: try optionalFloat(object, "repetitionPenalty", what),
231
+ stopSequences: try stringArray(object, "stopSequences", what),
232
+ grammar: try optionalString(object, "grammar", what),
233
+ tools: tools
234
+ )
235
+ }
236
+
237
+ static func encodeGenerationConfig(_ config: XybridGenerationConfig) -> JSObject {
238
+ var out: JSObject = [
239
+ "stopSequences": config.stopSequences,
240
+ "tools": config.tools.map {
241
+ ["name": $0.name, "description": $0.description, "parametersJson": $0.parametersJson]
242
+ },
243
+ ]
244
+ if let value = config.maxTokens { out["maxTokens"] = value }
245
+ if let value = config.temperature { out["temperature"] = Double(value) }
246
+ if let value = config.topP { out["topP"] = Double(value) }
247
+ if let value = config.minP { out["minP"] = Double(value) }
248
+ if let value = config.topK { out["topK"] = value }
249
+ if let value = config.repetitionPenalty { out["repetitionPenalty"] = Double(value) }
250
+ if let value = config.grammar { out["grammar"] = value }
251
+ return out
252
+ }
253
+
254
+ static func decodeAbortSignal(_ raw: String) throws -> XybridAbortSignal {
255
+ switch raw {
256
+ case "memoryPressureWarn": return .memoryPressureWarn
257
+ case "memoryPressureCritical": return .memoryPressureCritical
258
+ case "thermalHot": return .thermalHot
259
+ case "thermalCritical": return .thermalCritical
260
+ default: throw BridgeError.invalidArgument("unknown abort signal '\(raw)'")
261
+ }
262
+ }
263
+
264
+ /// Run options plus the two handles that ride along with them.
265
+ struct RunRequest {
266
+ var options: XybridRunOptions?
267
+ var context: String?
268
+ var cancel: String?
269
+ }
270
+
271
+ static func decodeRunRequest(_ any: Any?) throws -> RunRequest {
272
+ guard let any, !(any is NSNull) else { return RunRequest() }
273
+ let object = try self.object(any, "run options")
274
+ let what = "run options"
275
+ let options = XybridRunOptions(
276
+ generationConfig: try optionalObject(object, "generationConfig", what).map(decodeGenerationConfig),
277
+ abortOn: try stringArray(object, "abortOn", what).map(decodeAbortSignal),
278
+ fallbackToCloud: try optionalBool(object, "fallbackToCloud", what) ?? false,
279
+ maxGraceTokens: try optionalUInt32(object, "maxGraceTokens", what) ?? 0,
280
+ correlationId: try optionalString(object, "correlationId", what)
281
+ )
282
+ return RunRequest(
283
+ options: options,
284
+ context: try optionalString(object, "context", what),
285
+ cancel: try optionalString(object, "cancel", what)
286
+ )
287
+ }
288
+
289
+ static func decodeToolResult(_ any: Any) throws -> XybridToolResult {
290
+ let object = try self.object(any, "tool result")
291
+ return XybridToolResult(
292
+ callId: try string(object, "callId", "tool result"),
293
+ name: try string(object, "name", "tool result"),
294
+ contentJson: try string(object, "contentJson", "tool result")
295
+ )
296
+ }
297
+
298
+ // MARK: - Results
299
+
300
+ static func encodeOutputType(_ type: XybridOutputType) -> String {
301
+ switch type {
302
+ case .text: return "text"
303
+ case .audio: return "audio"
304
+ case .embedding: return "embedding"
305
+ case .unknown: return "unknown"
306
+ }
307
+ }
308
+
309
+ static func encodeExecutionTarget(_ target: XybridExecutionTarget) -> String {
310
+ target == .cloud ? "cloud" : "local"
311
+ }
312
+
313
+ /// Metrics in native stage order; LLM-only values only when reported.
314
+ static func encodeMetrics(_ metrics: XybridInferenceMetrics) -> JSObject {
315
+ var out: JSObject = [
316
+ "totalMs": metrics.totalMs,
317
+ "stageLatenciesMs": metrics.stageLatenciesMs.map {
318
+ ["stageId": $0.stageId, "latencyMs": $0.latencyMs]
319
+ },
320
+ ]
321
+ if let value = metrics.ttftMs { out["ttftMs"] = value }
322
+ if let value = metrics.tokensPerSecond { out["tokensPerSecond"] = Double(value) }
323
+ if let value = metrics.prefillTps { out["prefillTps"] = Double(value) }
324
+ if let value = metrics.decodeTps { out["decodeTps"] = Double(value) }
325
+ if let value = metrics.tokensOut { out["tokensOut"] = value }
326
+ return out
327
+ }
328
+
329
+ static func encodeToolCall(_ call: XybridToolCall) -> JSObject {
330
+ ["id": call.id, "name": call.name, "argumentsJson": call.argumentsJson]
331
+ }
332
+
333
+ static func encodeResult(_ result: XybridResult) -> JSObject {
334
+ var out: JSObject = [
335
+ "envelope": encodeEnvelope(result.envelope),
336
+ "outputType": encodeOutputType(result.outputType),
337
+ "modelId": result.modelId,
338
+ "latencyMs": result.latencyMs,
339
+ "executionTarget": encodeExecutionTarget(result.executionTarget),
340
+ "metrics": encodeMetrics(result.metrics),
341
+ "toolCalls": result.toolCalls.map(encodeToolCall),
342
+ ]
343
+ if let reasoning = result.reasoningContent { out["reasoningContent"] = reasoning }
344
+ return out
345
+ }
346
+
347
+ static func encodeStreamToken(_ token: XybridStreamToken) -> JSObject {
348
+ var out: JSObject = [
349
+ "token": token.token,
350
+ // u64 → Double: JS numbers are exact to 2^53; Int() could trap.
351
+ "index": Double(token.index),
352
+ "cumulativeText": token.cumulativeText,
353
+ "toolCalls": token.toolCalls.map(encodeToolCall),
354
+ ]
355
+ if let id = token.tokenId { out["tokenId"] = Double(id) }
356
+ if let reason = token.finishReason { out["finishReason"] = reason }
357
+ if let raw = token.rawText { out["rawText"] = raw }
358
+ return out
359
+ }
360
+
361
+ static func encodeDownloadStatus(_ status: XybridDownloadStatus) -> JSObject {
362
+ let state: String
363
+ switch status.state {
364
+ case .downloading: state = "downloading"
365
+ case .ready: state = "ready"
366
+ case .failed: state = "failed"
367
+ case .cancelled: state = "cancelled"
368
+ }
369
+ var out: JSObject = [
370
+ "state": state,
371
+ "progress": Double(status.progress),
372
+ "downloadedBytes": Double(status.downloadedBytes),
373
+ ]
374
+ // Absent, not 0, when the source declares no size.
375
+ if let total = status.totalBytes { out["totalBytes"] = Double(total) }
376
+ return out
377
+ }
378
+
379
+ static func encodeVoice(_ voice: XybridVoiceInfo) -> JSObject {
380
+ var out: JSObject = ["id": voice.id, "name": voice.name]
381
+ if let gender = voice.gender { out["gender"] = gender }
382
+ if let language = voice.language { out["language"] = language }
383
+ if let style = voice.style { out["style"] = style }
384
+ return out
385
+ }
386
+
387
+ static func encodeCacheStatus(_ status: XybridCacheStatus) -> JSObject {
388
+ [
389
+ "totalSizeBytes": Double(status.totalSizeBytes),
390
+ "entryCount": status.entryCount,
391
+ "modelCount": status.modelCount,
392
+ "extractedModelCount": status.extractedModelCount,
393
+ "cacheRoot": status.cacheRoot,
394
+ ]
395
+ }
396
+
397
+ static func encodeCacheEntry(_ entry: XybridCacheEntry) -> JSObject {
398
+ let location: String
399
+ switch entry.location {
400
+ case .registry: location = "registry"
401
+ case .extracted: location = "extracted"
402
+ case .huggingFace: location = "huggingFace"
403
+ case .huggingFaceHub: location = "huggingFaceHub"
404
+ }
405
+ return [
406
+ "modelId": entry.modelId,
407
+ "location": location,
408
+ "path": entry.path,
409
+ "sizeBytes": Double(entry.sizeBytes),
410
+ ]
411
+ }
412
+
413
+ static func encodeStageResult(_ stage: XybridStageResult) -> JSObject {
414
+ [
415
+ "stageId": stage.stageId,
416
+ "envelope": encodeEnvelope(stage.envelope),
417
+ "outputType": encodeOutputType(stage.outputType),
418
+ "latencyMs": stage.latencyMs,
419
+ "executionTarget": encodeExecutionTarget(stage.executionTarget),
420
+ "metrics": encodeMetrics(stage.metrics),
421
+ ]
422
+ }
423
+
424
+ static func encodePipelineResult(_ result: XybridPipelineResult) -> JSObject {
425
+ [
426
+ "envelope": encodeEnvelope(result.envelope),
427
+ "outputType": encodeOutputType(result.outputType),
428
+ "latencyMs": result.latencyMs,
429
+ "stages": result.stages.map(encodeStageResult),
430
+ ]
431
+ }
432
+
433
+ static func encodePartial(_ partial: XybridPartialResult) -> JSObject {
434
+ [
435
+ "text": partial.text,
436
+ "isStable": partial.isStable,
437
+ "chunkSequence": Double(partial.chunkSequence),
438
+ "audioDurationMs": Double(partial.audioDurationMs),
439
+ ]
440
+ }
441
+
442
+ // MARK: - Live ASR
443
+
444
+ static func decodeStreamingConfig(_ any: Any?) throws -> XybridStreamingConfig {
445
+ let object: JSObject = (any == nil || any is NSNull) ? [:] : try self.object(any, "streaming config")
446
+ let what = "streaming config"
447
+ let vad: XybridVadMode
448
+ if let vadObject = try optionalObject(object, "vad", what) {
449
+ vad = .enabled(modelDir: filePath(try string(vadObject, "modelDir", "streaming config vad")))
450
+ } else {
451
+ vad = .off
452
+ }
453
+ return XybridStreamingConfig(
454
+ sampleRate: try optionalUInt32(object, "sampleRate", what) ?? 16_000,
455
+ vad: vad,
456
+ vadThreshold: try optionalFloat(object, "vadThreshold", what) ?? 0.5,
457
+ language: try optionalString(object, "language", what),
458
+ audioCtx: try optionalUInt32(object, "audioCtx", what)
459
+ )
460
+ }
461
+
462
+ // MARK: - Errors
463
+
464
+ /// The stable `xybrid_*` code for an SDK error (see src/errors.ts).
465
+ static func code(for error: XybridError) -> String {
466
+ switch error {
467
+ case .modelNotFound: return "xybrid_model_not_found"
468
+ case .directoryNotFound: return "xybrid_directory_not_found"
469
+ case .metadataNotFound: return "xybrid_metadata_not_found"
470
+ case .metadataInvalid: return "xybrid_metadata_invalid"
471
+ case .loadError: return "xybrid_load_error"
472
+ case .inferenceError: return "xybrid_inference_error"
473
+ case .abortedForCloudFallback: return "xybrid_aborted_cloud_fallback"
474
+ case .streamingNotSupported: return "xybrid_streaming_unsupported"
475
+ case .notLoaded: return "xybrid_not_loaded"
476
+ case .configError: return "xybrid_config_error"
477
+ case .networkError: return "xybrid_network_error"
478
+ case .offline: return "xybrid_offline"
479
+ case .ioError: return "xybrid_io_error"
480
+ case .cacheError: return "xybrid_cache_error"
481
+ case .pipelineError: return "xybrid_pipeline_error"
482
+ case .circuitOpen: return "xybrid_circuit_open"
483
+ case .rateLimited: return "xybrid_rate_limited"
484
+ case .timeout: return "xybrid_timeout"
485
+ case .missingArtifact: return "xybrid_missing_artifact"
486
+ case .unsupportedModelCapability: return "xybrid_unsupported_model_capability"
487
+ case .unsupportedBackendCapability: return "xybrid_unsupported_backend_capability"
488
+ case .invalidImage: return "xybrid_invalid_image"
489
+ case .cancelled: return "xybrid_cancelled"
490
+ }
491
+ }
492
+
493
+ /// `(code, message)` for anything a bridged call can throw.
494
+ static func rejection(for error: Error) -> (code: String, message: String) {
495
+ switch error {
496
+ case let error as XybridError:
497
+ return (code(for: error), error.errorDescription ?? "Xybrid error")
498
+ case BridgeError.invalidArgument(let message):
499
+ return ("xybrid_invalid_argument", message)
500
+ case BridgeError.handle(let message):
501
+ return ("xybrid_handle", message)
502
+ case BridgeError.config(let message):
503
+ return ("xybrid_config_error", message)
504
+ default:
505
+ return ("xybrid_unknown", error.localizedDescription)
506
+ }
507
+ }
508
+ }
@@ -0,0 +1,191 @@
1
+ import Foundation
2
+
3
+ /// Every native object JS holds — models, conversation contexts, cancellation
4
+ /// tokens, token streams, downloads, pipelines, live-ASR sessions — keyed by
5
+ /// an opaque `"<kind>:<uuid>"` string.
6
+ ///
7
+ /// Lifetimes are ARC's: in-flight calls hold their own strong references, so
8
+ /// disposing a handle while a call uses it only drops the registry's
9
+ /// reference and the object is freed when that call returns. `onDispose`
10
+ /// runs the side effects that must happen immediately (aborting a stream,
11
+ /// stopping a session) and entries owned by a disposed handle go with it.
12
+ final class XybridHandles {
13
+ private struct Entry {
14
+ let object: AnyObject
15
+ let owner: String?
16
+ let onDispose: (() -> Void)?
17
+ }
18
+
19
+ private let lock = NSLock()
20
+ private var entries: [String: Entry] = [:]
21
+
22
+ /// Register `object` and return its new handle.
23
+ func insert(_ object: AnyObject,
24
+ kind: String,
25
+ owner: String? = nil,
26
+ onDispose: (() -> Void)? = nil) -> String {
27
+ let handle = "\(kind):\(UUID().uuidString)"
28
+ lock.lock()
29
+ entries[handle] = Entry(object: object, owner: owner, onDispose: onDispose)
30
+ lock.unlock()
31
+ return handle
32
+ }
33
+
34
+ /// The live object behind `handle`, or a `xybrid_handle` error when the
35
+ /// handle is unknown, disposed, or names another kind of object.
36
+ func get<T>(_ handle: String, as type: T.Type) throws -> T {
37
+ lock.lock()
38
+ let object = entries[handle]?.object
39
+ lock.unlock()
40
+ guard let typed = object as? T else {
41
+ throw BridgeError.handle("unknown or released handle: \(handle)")
42
+ }
43
+ return typed
44
+ }
45
+
46
+ /// Like `get`, but `nil` instead of an error.
47
+ func find<T>(_ handle: String, as type: T.Type) -> T? {
48
+ try? get(handle, as: type)
49
+ }
50
+
51
+ /// Dispose `handle` and every entry it owns. Idempotent.
52
+ func dispose(_ handle: String) {
53
+ lock.lock()
54
+ var removed: [Entry] = []
55
+ if let entry = entries.removeValue(forKey: handle) { removed.append(entry) }
56
+ for (key, entry) in entries where entry.owner == handle {
57
+ entries.removeValue(forKey: key)
58
+ removed.append(entry)
59
+ }
60
+ lock.unlock()
61
+ // Side effects outside the lock: they call into the SDK.
62
+ removed.forEach { $0.onDispose?() }
63
+ }
64
+
65
+ /// Dispose everything (module invalidation: reload, teardown).
66
+ func disposeAll() {
67
+ lock.lock()
68
+ let removed = Array(entries.values)
69
+ entries.removeAll()
70
+ lock.unlock()
71
+ removed.forEach { $0.onDispose?() }
72
+ }
73
+
74
+ /// Every live object of type `T` (e.g. to cancel all tokens on teardown).
75
+ func all<T>(_ type: T.Type) -> [T] {
76
+ lock.lock()
77
+ defer { lock.unlock() }
78
+ return entries.values.compactMap { $0.object as? T }
79
+ }
80
+ }
81
+
82
+ /// A pull-based token stream: the model it runs on (bolt stream ids are
83
+ /// model-scoped) and the stop button that aborts it.
84
+ final class XybridTokenStreamEntry {
85
+ let model: XybridModel
86
+ let streamId: UInt64
87
+ let cancel: XybridCancellationToken
88
+
89
+ init(model: XybridModel, streamId: UInt64, cancel: XybridCancellationToken) {
90
+ self.model = model
91
+ self.streamId = streamId
92
+ self.cancel = cancel
93
+ }
94
+
95
+ /// Abort generation now. Safe while a `streamNext` is in flight: closing
96
+ /// only forgets the session id, and the in-flight pull keeps its own
97
+ /// reference until it returns.
98
+ func abort() {
99
+ cancel.cancel()
100
+ model.streamClose(streamId: streamId)
101
+ }
102
+ }
103
+
104
+ /// A live-ASR session plus a pump that buffers its partial transcripts for
105
+ /// pull-based reads from JS.
106
+ final class XybridSessionEntry {
107
+ let session: XybridStreamingSession
108
+ private let partials: PartialQueue
109
+
110
+ init(session: XybridStreamingSession) {
111
+ self.session = session
112
+ self.partials = PartialQueue(stream: session.partials())
113
+ }
114
+
115
+ deinit {
116
+ partials.stop()
117
+ }
118
+
119
+ /// The next partial transcript, or `nil` once the session has finished.
120
+ func nextPartial() async -> XybridPartialResult? {
121
+ await partials.next()
122
+ }
123
+ }
124
+
125
+ /// Drains an `AsyncStream` on its own task so JS can pull one element per
126
+ /// call. (An `AsyncStream` iterator must never be awaited from two tasks at
127
+ /// once, which overlapping bridge calls could otherwise do.)
128
+ private final class PartialQueue: @unchecked Sendable {
129
+ /// Partials are cumulative — each supersedes the last — so a reader that
130
+ /// falls this far behind only loses stale text.
131
+ private static let capacity = 64
132
+
133
+ private let lock = NSLock()
134
+ private var buffer: [XybridPartialResult] = []
135
+ private var waiters: [CheckedContinuation<XybridPartialResult?, Never>] = []
136
+ private var finished = false
137
+ private var pump: Task<Void, Never>?
138
+
139
+ init(stream: AsyncStream<XybridPartialResult>) {
140
+ pump = Task.detached { [weak self] in
141
+ for await partial in stream {
142
+ self?.push(partial)
143
+ }
144
+ self?.finish()
145
+ }
146
+ }
147
+
148
+ func next() async -> XybridPartialResult? {
149
+ await withCheckedContinuation { continuation in
150
+ lock.lock()
151
+ if !buffer.isEmpty {
152
+ let partial = buffer.removeFirst()
153
+ lock.unlock()
154
+ continuation.resume(returning: partial)
155
+ } else if finished {
156
+ lock.unlock()
157
+ continuation.resume(returning: nil)
158
+ } else {
159
+ waiters.append(continuation)
160
+ lock.unlock()
161
+ }
162
+ }
163
+ }
164
+
165
+ func stop() {
166
+ pump?.cancel()
167
+ finish()
168
+ }
169
+
170
+ private func push(_ partial: XybridPartialResult) {
171
+ lock.lock()
172
+ if !waiters.isEmpty {
173
+ let waiter = waiters.removeFirst()
174
+ lock.unlock()
175
+ waiter.resume(returning: partial)
176
+ return
177
+ }
178
+ buffer.append(partial)
179
+ if buffer.count > Self.capacity { buffer.removeFirst(buffer.count - Self.capacity) }
180
+ lock.unlock()
181
+ }
182
+
183
+ private func finish() {
184
+ lock.lock()
185
+ finished = true
186
+ let pending = waiters
187
+ waiters.removeAll()
188
+ lock.unlock()
189
+ pending.forEach { $0.resume(returning: nil) }
190
+ }
191
+ }