@npaw/forge-react-native 10.0.4 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +14 -0
  3. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +82 -0
  4. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +9 -9
  5. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -0
  6. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +71 -0
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +71 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +71 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  13. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +250 -0
  14. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
  15. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +147 -6
  16. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCommands.swift +113 -0
  17. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +72 -2
  18. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +46 -2
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +53 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +14 -0
  21. package/ios/ForgeModuleImpl.swift +66 -0
  22. package/ios/NPAWForgeTurboModule.mm +16 -1
  23. package/package.json +1 -1
  24. package/src/NPAWForge.ts +131 -1
  25. package/src/events.ts +17 -0
  26. package/src/index.ts +6 -2
  27. package/src/specs/NativeNPAWForge.ts +9 -0
  28. package/src/types.ts +21 -0
@@ -36,11 +36,17 @@ public final class ForgeModuleImpl: NSObject {
36
36
  /// Set by the TurboModule shell; receives the channel body for every line
37
37
  /// of the kernel's rendered decision log.
38
38
  @objc public var logSink: (([String: Any]) -> Void)?
39
+ /// Set by the TurboModule shell; receives the channel body for every
40
+ /// subscribed remote command.
41
+ @objc public var commandSink: (([String: Any]) -> Void)?
39
42
 
40
43
  private let lock = NSLock()
41
44
  private var instances: [String: NPAWForge] = [:]
42
45
  private var subscriptions: [String: ForgeEventSubscription] = [:]
43
46
  private var logSubscriptions: [String: ForgeEventSubscription] = [:]
47
+ /// Remote command subscriptions per instance, one native listener per
48
+ /// subscribed name; explicit, so they do not follow `listenerCount`.
49
+ private var commandSubscriptions: [String: [String: ForgeEventSubscription]] = [:]
44
50
  private var adReporters: [Int: ManualAdReporter] = [:]
45
51
  private var nextInstanceId = 1
46
52
  private var nextReporterId = 1
@@ -106,6 +112,7 @@ public final class ForgeModuleImpl: NSObject {
106
112
  let forge = instances.removeValue(forKey: instanceId)
107
113
  let subscription = subscriptions.removeValue(forKey: instanceId)
108
114
  let logSubscription = logSubscriptions.removeValue(forKey: instanceId)
115
+ let commandSubscription = commandSubscriptions.removeValue(forKey: instanceId)
109
116
  lock.unlock()
110
117
 
111
118
  guard let forge else {
@@ -114,9 +121,66 @@ public final class ForgeModuleImpl: NSObject {
114
121
  }
115
122
  subscription?.cancel()
116
123
  logSubscription?.cancel()
124
+ commandSubscription?.values.forEach { $0.cancel() }
117
125
  resolve(Self.outcomeToDictionary(forge.destroy()))
118
126
  }
119
127
 
128
+ // MARK: - Remote commands
129
+
130
+ @objc public func setCommandSubscriptions(
131
+ _ instanceId: String,
132
+ names: [String],
133
+ resolve: @escaping ForgeResolveBlock,
134
+ reject: @escaping ForgeRejectBlock
135
+ ) {
136
+ lock.lock()
137
+ let forge = instances[instanceId]
138
+ var current = commandSubscriptions[instanceId] ?? [:]
139
+ lock.unlock()
140
+ guard let forge else {
141
+ reject(Self.errUnknownInstance, "No NPAWForge instance '\(instanceId)'")
142
+ return
143
+ }
144
+ let wanted = Set(names.filter { !$0.isEmpty })
145
+ var cancelled: [ForgeEventSubscription] = []
146
+ for name in Array(current.keys) where !wanted.contains(name) {
147
+ if let subscription = current.removeValue(forKey: name) {
148
+ cancelled.append(subscription)
149
+ }
150
+ }
151
+ for name in wanted where current[name] == nil {
152
+ // One native listener per name; the JS registry fans out to its
153
+ // own listeners and answers by id through answerCommand.
154
+ current[name] = forge.onCommand(name) { [weak self] command, _ in
155
+ guard let self, let sink = self.commandSink else { return }
156
+ sink([
157
+ "instanceId": instanceId,
158
+ "id": command.id,
159
+ "name": command.name,
160
+ "args": command.args,
161
+ ])
162
+ }
163
+ }
164
+ lock.lock()
165
+ commandSubscriptions[instanceId] = current
166
+ lock.unlock()
167
+ cancelled.forEach { $0.cancel() }
168
+ resolve(Self.outcomeToDictionary(.accepted))
169
+ }
170
+
171
+ @objc public func answerCommand(
172
+ _ instanceId: String,
173
+ commandId: String,
174
+ handled: Bool,
175
+ details: [String: Any]?,
176
+ resolve: @escaping ForgeResolveBlock,
177
+ reject: @escaping ForgeRejectBlock
178
+ ) {
179
+ withInstance(instanceId, resolve: resolve, reject: reject) {
180
+ $0.answerCommand(id: commandId, handled: handled, details: Self.stringMap(details))
181
+ }
182
+ }
183
+
120
184
  @objc public func setLogLevel(
121
185
  _ instanceId: String,
122
186
  level: String,
@@ -959,9 +1023,11 @@ public final class ForgeModuleImpl: NSObject {
959
1023
  @objc public func invalidate() {
960
1024
  lock.lock()
961
1025
  let cancelled = Array(subscriptions.values) + Array(logSubscriptions.values)
1026
+ + commandSubscriptions.values.flatMap { Array($0.values) }
962
1027
  let live = Array(instances.values)
963
1028
  subscriptions.removeAll()
964
1029
  logSubscriptions.removeAll()
1030
+ commandSubscriptions.removeAll()
965
1031
  instances.removeAll()
966
1032
  adReporters.removeAll()
967
1033
  listenerCount = 0
@@ -26,6 +26,7 @@
26
26
 
27
27
  static NSString *const kForgeEventChannel = @"NPAWForgeEvent";
28
28
  static NSString *const kForgeLogChannel = @"NPAWForgeLog";
29
+ static NSString *const kForgeCommandChannel = @"NPAWForgeCommand";
29
30
 
30
31
  @interface NPAWForgeReactNative : RCTEventEmitter
31
32
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -52,13 +53,16 @@ RCT_EXPORT_MODULE(NPAWForgeRN)
52
53
  ForgeModuleImpl.shared.logSink = ^(NSDictionary<NSString *, id> *body) {
53
54
  [weakSelf sendEventWithName:kForgeLogChannel body:body];
54
55
  };
56
+ ForgeModuleImpl.shared.commandSink = ^(NSDictionary<NSString *, id> *body) {
57
+ [weakSelf sendEventWithName:kForgeCommandChannel body:body];
58
+ };
55
59
  }
56
60
  return self;
57
61
  }
58
62
 
59
63
  - (NSArray<NSString *> *)supportedEvents
60
64
  {
61
- return @[ kForgeEventChannel, kForgeLogChannel ];
65
+ return @[ kForgeEventChannel, kForgeLogChannel, kForgeCommandChannel ];
62
66
  }
63
67
 
64
68
  - (void)startObserving
@@ -75,6 +79,7 @@ RCT_EXPORT_MODULE(NPAWForgeRN)
75
79
  {
76
80
  ForgeModuleImpl.shared.eventSink = nil;
77
81
  ForgeModuleImpl.shared.logSink = nil;
82
+ ForgeModuleImpl.shared.commandSink = nil;
78
83
  [ForgeModuleImpl.shared invalidate];
79
84
  [super invalidate];
80
85
  }
@@ -105,6 +110,16 @@ RCT_EXPORT_METHOD(setLogLevel : (NSString *)instanceId level : (NSString *)level
105
110
  [ForgeModuleImpl.shared setLogLevel:instanceId level:level resolve:resolve reject:ForgeMakeReject(reject)];
106
111
  }
107
112
 
113
+ RCT_EXPORT_METHOD(setCommandSubscriptions : (NSString *)instanceId names : (NSArray<NSString *> *)names resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
114
+ {
115
+ [ForgeModuleImpl.shared setCommandSubscriptions:instanceId names:names ?: @[] resolve:resolve reject:ForgeMakeReject(reject)];
116
+ }
117
+
118
+ RCT_EXPORT_METHOD(answerCommand : (NSString *)instanceId commandId : (NSString *)commandId handled : (BOOL)handled details : (NSDictionary *)details resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
119
+ {
120
+ [ForgeModuleImpl.shared answerCommand:instanceId commandId:commandId handled:handled details:details resolve:resolve reject:ForgeMakeReject(reject)];
121
+ }
122
+
108
123
  RCT_EXPORT_METHOD(startSession : (NSString *)instanceId metadata : (NSDictionary *)metadata resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
109
124
  {
110
125
  [ForgeModuleImpl.shared startSession:instanceId metadata:metadata resolve:resolve reject:ForgeMakeReject(reject)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npaw/forge-react-native",
3
- "version": "10.0.4",
3
+ "version": "10.2.0",
4
4
  "description": "NPAW Forge analytics for React Native: New Architecture TurboModule facade over the native Forge Android/Apple SDKs.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Victor Soto <victor.soto@nicepeopleatwork.com>",
package/src/NPAWForge.ts CHANGED
@@ -14,8 +14,12 @@
14
14
 
15
15
  import NativeNPAWForge from './specs/NativeNPAWForge';
16
16
  import { ForgeVideo } from './ForgeVideo';
17
- import { subscribeInstanceEvents, subscribeInstanceLogs } from './events';
17
+ import { subscribeInstanceCommands, subscribeInstanceEvents, subscribeInstanceLogs } from './events';
18
18
  import type {
19
+ ForgeCommand,
20
+ ForgeCommandAnswer,
21
+ ForgeCommandEvent,
22
+ ForgeCommandListener,
19
23
  ForgeConfig,
20
24
  ForgeEventSubscription,
21
25
  ForgeNetworkRequestRecord,
@@ -32,7 +36,18 @@ function asOutcome(value: Promise<unknown>): Promise<ForgeOutcome> {
32
36
  return value as Promise<ForgeOutcome>;
33
37
  }
34
38
 
39
+ /** Distinct command names one host may subscribe to (the kernel cap). */
40
+ export const MAX_COMMAND_SUBSCRIPTIONS = 64;
41
+
42
+ const COMMAND_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
43
+
44
+ const NOOP_SUBSCRIPTION: ForgeEventSubscription = { remove: () => undefined };
45
+
35
46
  export class NPAWForge {
47
+ private readonly commandListeners = new Map<string, Set<ForgeCommandListener>>();
48
+ private commandChannel: ForgeEventSubscription | undefined;
49
+ private commandsClosed = false;
50
+
36
51
  private constructor(readonly instanceId: string) {}
37
52
 
38
53
  /** Builds a Forge instance; resolves once the native runtime is live. */
@@ -44,6 +59,12 @@ export class NPAWForge {
44
59
  // ---- lifecycle ----
45
60
 
46
61
  destroy(): Promise<ForgeOutcome> {
62
+ // Listeners go first: the native subscriptions die with the instance and
63
+ // a late answer must not reach a destroyed runtime.
64
+ this.commandsClosed = true;
65
+ this.commandListeners.clear();
66
+ this.commandChannel?.remove();
67
+ this.commandChannel = undefined;
47
68
  return asOutcome(NativeNPAWForge.destroy(this.instanceId));
48
69
  }
49
70
 
@@ -176,6 +197,115 @@ export class NPAWForge {
176
197
  return subscribeInstanceEvents(this.instanceId, callback);
177
198
  }
178
199
 
200
+ // ---- remote commands ----
201
+
202
+ /**
203
+ * Listens for a remote command by name. The backend delivers commands in
204
+ * the responses of Forge's own analytics requests, so they arrive only
205
+ * while events flow; a command nobody subscribed to is answered
206
+ * `handled=false` by the kernel and never re-delivered, so register early.
207
+ * Every listener of the name runs; call `answer(handled, details?)` at most
208
+ * once per listener, the first answer per command id is reported as a
209
+ * `command-answer` event. Blank or malformed names and the 65th distinct
210
+ * name are refused with a console warning.
211
+ */
212
+ onCommand(name: string, listener: ForgeCommandListener): ForgeEventSubscription {
213
+ const normalized = typeof name === 'string' ? name.trim() : '';
214
+ if (!COMMAND_NAME_PATTERN.test(normalized)) {
215
+ console.warn('[NPAWForge] ignored command listener: names are 1-64 characters of [A-Za-z0-9_.-]');
216
+ return NOOP_SUBSCRIPTION;
217
+ }
218
+ if (this.commandsClosed) {
219
+ return NOOP_SUBSCRIPTION;
220
+ }
221
+ let listeners = this.commandListeners.get(normalized);
222
+ if (!listeners) {
223
+ if (this.commandListeners.size >= MAX_COMMAND_SUBSCRIPTIONS) {
224
+ console.warn(
225
+ `[NPAWForge] ignored command listener: at most ${MAX_COMMAND_SUBSCRIPTIONS} distinct command names can be subscribed`
226
+ );
227
+ return NOOP_SUBSCRIPTION;
228
+ }
229
+ listeners = new Set<ForgeCommandListener>();
230
+ this.commandListeners.set(normalized, listeners);
231
+ }
232
+ listeners.add(listener);
233
+ if (!this.commandChannel) {
234
+ this.commandChannel = subscribeInstanceCommands(this.instanceId, (event) => this.dispatchCommand(event));
235
+ }
236
+ void this.syncCommandSubscriptions();
237
+ let active = true;
238
+ return {
239
+ remove: () => {
240
+ if (!active) {
241
+ return;
242
+ }
243
+ active = false;
244
+ const current = this.commandListeners.get(normalized);
245
+ if (!current) {
246
+ return;
247
+ }
248
+ current.delete(listener);
249
+ if (current.size === 0) {
250
+ this.commandListeners.delete(normalized);
251
+ }
252
+ if (this.commandListeners.size === 0) {
253
+ this.commandChannel?.remove();
254
+ this.commandChannel = undefined;
255
+ }
256
+ if (!this.commandsClosed) {
257
+ void this.syncCommandSubscriptions();
258
+ }
259
+ },
260
+ };
261
+ }
262
+
263
+ /** The remote command names currently subscribed, sorted. */
264
+ commandSubscriptions(): string[] {
265
+ return Array.from(this.commandListeners.keys()).sort();
266
+ }
267
+
268
+ /**
269
+ * Answers a delivered remote command by id, for hosts that correlate
270
+ * answers themselves. Prefer the `answer` handed to the listener. The
271
+ * kernel keeps the first answer per command id.
272
+ */
273
+ answerCommand(commandId: string, handled: boolean, details: Record<string, string> = {}): Promise<ForgeOutcome> {
274
+ if (this.commandsClosed) {
275
+ // The runtime is gone (or going): a late answer must not reach native.
276
+ return Promise.resolve({ code: 'ignored_invalid_state', isAccepted: false } as ForgeOutcome);
277
+ }
278
+ return asOutcome(NativeNPAWForge.answerCommand(this.instanceId, commandId, Boolean(handled), details));
279
+ }
280
+
281
+ private syncCommandSubscriptions(): Promise<ForgeOutcome> {
282
+ return asOutcome(NativeNPAWForge.setCommandSubscriptions(this.instanceId, this.commandSubscriptions()));
283
+ }
284
+
285
+ private dispatchCommand(event: ForgeCommandEvent): void {
286
+ const listeners = this.commandListeners.get(event.name);
287
+ if (!listeners || listeners.size === 0) {
288
+ return;
289
+ }
290
+ const args = Array.isArray(event.args) ? event.args.map((arg) => String(arg)) : [];
291
+ for (const listener of Array.from(listeners)) {
292
+ let answered = false;
293
+ const answer: ForgeCommandAnswer = (handled, details) => {
294
+ if (answered) {
295
+ return Promise.resolve({ code: 'ignored_duplicate', isAccepted: false } as ForgeOutcome);
296
+ }
297
+ answered = true;
298
+ return this.answerCommand(event.id, handled, details ?? {});
299
+ };
300
+ const command: ForgeCommand = { id: event.id, name: event.name, args: [...args] };
301
+ try {
302
+ listener(command, answer);
303
+ } catch (error) {
304
+ console.warn('[NPAWForge] command listener failed', error);
305
+ }
306
+ }
307
+ }
308
+
179
309
  /**
180
310
  * Live feed of the kernel's rendered decision log for this instance - the
181
311
  * same lines `logLevel: 'trace'` writes to the platform console, without
package/src/events.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { NativeEventEmitter, NativeModules } from 'react-native';
16
16
  import NativeNPAWForge from './specs/NativeNPAWForge';
17
17
  import type {
18
+ ForgeCommandEvent,
18
19
  ForgeEventSubscription,
19
20
  ForgeRecordedEvent,
20
21
  ForgeRenderedLog,
@@ -22,6 +23,7 @@ import type {
22
23
 
23
24
  export const FORGE_EVENT_CHANNEL = 'NPAWForgeEvent';
24
25
  export const FORGE_LOG_CHANNEL = 'NPAWForgeLog';
26
+ export const FORGE_COMMAND_CHANNEL = 'NPAWForgeCommand';
25
27
 
26
28
  const emitter = new NativeEventEmitter(
27
29
  // The spec module implements addListener/removeListeners; iOS needs the
@@ -58,3 +60,18 @@ export function subscribeInstanceLogs(
58
60
  );
59
61
  return { remove: () => subscription.remove() };
60
62
  }
63
+
64
+ export function subscribeInstanceCommands(
65
+ instanceId: string,
66
+ callback: (command: ForgeCommandEvent) => void
67
+ ): ForgeEventSubscription {
68
+ const subscription = emitter.addListener(
69
+ FORGE_COMMAND_CHANNEL,
70
+ (command: ForgeCommandEvent) => {
71
+ if (command.instanceId === instanceId) {
72
+ callback(command);
73
+ }
74
+ }
75
+ );
76
+ return { remove: () => subscription.remove() };
77
+ }
package/src/index.ts CHANGED
@@ -12,10 +12,10 @@
12
12
  * @description: Public exports for @npaw/forge-react-native.
13
13
  */
14
14
 
15
- export { NPAWForge, DEFAULT_VIDEO_IDENTIFIER } from './NPAWForge';
15
+ export { NPAWForge, DEFAULT_VIDEO_IDENTIFIER, MAX_COMMAND_SUBSCRIPTIONS } from './NPAWForge';
16
16
  export { ForgeVideo } from './ForgeVideo';
17
17
  export { ForgeAdReporter } from './ForgeAdReporter';
18
- export { FORGE_EVENT_CHANNEL, FORGE_LOG_CHANNEL } from './events';
18
+ export { FORGE_COMMAND_CHANNEL, FORGE_EVENT_CHANNEL, FORGE_LOG_CHANNEL } from './events';
19
19
  export {
20
20
  installErrorCapture,
21
21
  trackScreenView,
@@ -31,6 +31,10 @@ export type {
31
31
  ForgeViewToken,
32
32
  } from './sections';
33
33
  export type {
34
+ ForgeCommand,
35
+ ForgeCommandAnswer,
36
+ ForgeCommandEvent,
37
+ ForgeCommandListener,
34
38
  ForgeConfig,
35
39
  ForgeEventSubscription,
36
40
  ForgeLogLevelName,
@@ -163,6 +163,15 @@ export interface Spec extends TurboModule {
163
163
  adFireBreakStop(reporterId: number, params: UnsafeObject): Promise<UnsafeObject>;
164
164
  adFireDestroy(reporterId: number): Promise<UnsafeObject>;
165
165
 
166
+ // ---- remote commands ----
167
+ setCommandSubscriptions(instanceId: string, names: string[]): Promise<UnsafeObject>;
168
+ answerCommand(
169
+ instanceId: string,
170
+ commandId: string,
171
+ handled: boolean,
172
+ details: UnsafeObject
173
+ ): Promise<UnsafeObject>;
174
+
166
175
  // ---- event feed (classic emitter contract) ----
167
176
  addListener(eventName: string): void;
168
177
  removeListeners(count: number): void;
package/src/types.ts CHANGED
@@ -148,3 +148,24 @@ export type ForgeNetworkRequestRecord = {
148
148
  };
149
149
 
150
150
  export type ForgeEventSubscription = { remove: () => void };
151
+
152
+ /**
153
+ * A remote command the backend addressed to this client (delivered in the
154
+ * `npaw-command` header of an NQS response). `args` are positional strings in
155
+ * header order; `id` is opaque and echoed as `commandId` on the answer.
156
+ */
157
+ export type ForgeCommand = { id: string; name: string; args: string[] };
158
+
159
+ /**
160
+ * Answers a delivered command. Call it at most once per listener: the first
161
+ * call reports `handled` plus optional flat string `details` as a
162
+ * `command-answer` analytics event, later calls resolve `ignored_duplicate`
163
+ * locally. Only the first answer per command id across all listeners is
164
+ * emitted by the kernel.
165
+ */
166
+ export type ForgeCommandAnswer = (handled: boolean, details?: Record<string, string>) => Promise<ForgeOutcome>;
167
+
168
+ export type ForgeCommandListener = (command: ForgeCommand, answer: ForgeCommandAnswer) => void;
169
+
170
+ /** Channel body of `NPAWForgeCommand`: the command plus its instance id. */
171
+ export type ForgeCommandEvent = ForgeCommand & { instanceId: string };