diff --git a/.github/workflows/scripts/functions/src/index.ts b/.github/workflows/scripts/functions/src/index.ts index 4d72f60ea0..2ca48c80e2 100644 --- a/.github/workflows/scripts/functions/src/index.ts +++ b/.github/workflows/scripts/functions/src/index.ts @@ -31,3 +31,11 @@ export { fetchAppCheckTokenV2 } from './fetchAppCheckToken'; export { sendFCM } from './sendFCM'; export { testFetchStream, testFetch } from './vertexaiFunctions'; + +export { + testStreamingCallable, + testProgressStream, + testComplexDataStream, + testStreamWithError, + testStreamResponse, +} from './testStreamingCallable'; diff --git a/.github/workflows/scripts/functions/src/testStreamingCallable.ts b/.github/workflows/scripts/functions/src/testStreamingCallable.ts new file mode 100644 index 0000000000..9180189a4d --- /dev/null +++ b/.github/workflows/scripts/functions/src/testStreamingCallable.ts @@ -0,0 +1,205 @@ +import { onCall, CallableRequest, CallableResponse } from 'firebase-functions/v2/https'; +import { logger } from 'firebase-functions/v2'; + +/** + * Test streaming callable function that sends multiple chunks of data + * This function demonstrates Server-Sent Events (SSE) streaming + */ +export const testStreamingCallable = onCall( + async ( + req: CallableRequest<{ count?: number; delay?: number }>, + response?: CallableResponse, + ) => { + const count = req.data.count || 5; + const delay = req.data.delay || 500; + + logger.info('testStreamingCallable called', { count, delay }); + + // Send chunks of data over time + for (let i = 0; i < count; i++) { + // Wait for the specified delay + await new Promise(resolve => setTimeout(resolve, delay)); + + if (response) { + await response.sendChunk({ + index: i, + message: `Chunk ${i + 1} of ${count}`, + timestamp: new Date().toISOString(), + data: { + value: i * 10, + isEven: i % 2 === 0, + }, + }); + } + } + + // Return final result + return { totalCount: count, message: 'Stream complete' }; + }, +); + +/** + * Test streaming callable that sends progressive updates + */ +export const testProgressStream = onCall( + async ( + req: CallableRequest<{ task?: string }>, + response?: CallableResponse, + ) => { + const task = req.data.task || 'Processing'; + + logger.info('testProgressStream called', { task }); + + const updates = [ + { progress: 0, status: 'Starting...', task }, + { progress: 25, status: 'Loading data...', task }, + { progress: 50, status: 'Processing data...', task }, + { progress: 75, status: 'Finalizing...', task }, + { progress: 100, status: 'Complete!', task }, + ]; + + for (const update of updates) { + await new Promise(resolve => setTimeout(resolve, 300)); + if (response) { + await response.sendChunk(update); + } + } + + return { success: true }; + }, +); + +/** + * Test streaming with complex data types + */ +export const testComplexDataStream = onCall( + async (req: CallableRequest, response?: CallableResponse) => { + logger.info('testComplexDataStream called'); + + const items = [ + { + id: 1, + name: 'Item One', + tags: ['test', 'streaming', 'firebase'], + metadata: { + created: new Date().toISOString(), + version: '1.0.0', + }, + }, + { + id: 2, + name: 'Item Two', + tags: ['react-native', 'functions'], + metadata: { + created: new Date().toISOString(), + version: '1.0.1', + }, + }, + { + id: 3, + name: 'Item Three', + tags: ['cloud', 'streaming'], + metadata: { + created: new Date().toISOString(), + version: '2.0.0', + }, + }, + ]; + + // Stream each item individually + for (const item of items) { + await new Promise(resolve => setTimeout(resolve, 200)); + if (response) { + await response.sendChunk(item); + } + } + + // Return summary + return { + summary: { + totalItems: items.length, + processedAt: new Date().toISOString(), + }, + }; + }, +); + +/** + * Test streaming with error handling + */ +export const testStreamWithError = onCall( + async ( + req: CallableRequest<{ shouldError?: boolean; errorAfter?: number }>, + response?: CallableResponse, + ) => { + const shouldError = req.data.shouldError !== false; + const errorAfter = req.data.errorAfter || 2; + + logger.info('testStreamWithError called', { shouldError, errorAfter }); + + for (let i = 0; i < 5; i++) { + if (shouldError && i === errorAfter) { + throw new Error('Simulated streaming error after chunk ' + errorAfter); + } + + await new Promise(resolve => setTimeout(resolve, 300)); + if (response) { + await response.sendChunk({ + chunk: i, + message: `Processing chunk ${i + 1}`, + }); + } + } + + return { + success: true, + message: 'All chunks processed successfully', + }; + }, +); + +/** + * Test streaming callable that returns the type of data sent + * Similar to Dart's testStreamResponse - sends back the type of input data + */ +export const testStreamResponse = onCall( + async (req: CallableRequest, response?: CallableResponse) => { + logger.info('testStreamResponse called', { data: req.data }); + + // Determine the type of the input data + let partialData: string; + if (req.data === null || req.data === undefined) { + partialData = 'null'; + } else if (typeof req.data === 'string') { + partialData = 'string'; + } else if (typeof req.data === 'number') { + partialData = 'number'; + } else if (typeof req.data === 'boolean') { + partialData = 'boolean'; + } else if (Array.isArray(req.data)) { + partialData = 'array'; + } else if (typeof req.data === 'object') { + // For deep maps, check if it has the expected structure + if (req.data.type === 'deepMap' && req.data.inputData) { + partialData = req.data.inputData; + } else { + partialData = 'object'; + } + } else { + partialData = 'unknown'; + } + + // Send chunk with the type information + if (response) { + await response.sendChunk({ + partialData, + }); + } + + // Return final result + return { + partialData, + type: typeof req.data, + }; + }, +); diff --git a/jest.setup.ts b/jest.setup.ts index e04af31fc6..54f94775c6 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -144,6 +144,16 @@ jest.doMock('react-native', () => { checkForUpdate: jest.fn(), signOutTester: jest.fn(), }, + RNFBFunctionsModule: { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }, + NativeRNFBTurboFunctions: { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }, RNFBCrashlyticsModule: { isCrashlyticsCollectionEnabled: false, checkForUnsentReports: jest.fn(), diff --git a/packages/app/android/src/main/java/io/invertase/firebase/common/TaskExecutorService.java b/packages/app/android/src/main/java/io/invertase/firebase/common/TaskExecutorService.java index 7ff50feb78..e78ba65acb 100644 --- a/packages/app/android/src/main/java/io/invertase/firebase/common/TaskExecutorService.java +++ b/packages/app/android/src/main/java/io/invertase/firebase/common/TaskExecutorService.java @@ -37,7 +37,7 @@ public class TaskExecutorService { private final int keepAliveSeconds; private static final Map executors = new HashMap<>(); - TaskExecutorService(String name) { + public TaskExecutorService(String name) { this.name = name; ReactNativeFirebaseJSON json = ReactNativeFirebaseJSON.getSharedInstance(); this.maximumPoolSize = json.getIntValue(MAXIMUM_POOL_SIZE_KEY, 1); diff --git a/packages/functions/RNFBFunctions.podspec b/packages/functions/RNFBFunctions.podspec index aa5a6a9aa6..6bae83e449 100644 --- a/packages/functions/RNFBFunctions.podspec +++ b/packages/functions/RNFBFunctions.podspec @@ -27,7 +27,9 @@ Pod::Spec.new do |s| s.ios.deployment_target = firebase_ios_target s.macos.deployment_target = firebase_macos_target s.tvos.deployment_target = firebase_tvos_target - s.source_files = 'ios/**/*.{h,m,mm,cpp}' + s.swift_version = '5.0' + s.source_files = 'ios/**/*.{h,m,mm,cpp,swift}' + s.private_header_files = "ios/**/*.h" s.exclude_files = 'ios/generated/RCTThirdPartyComponentsProvider.*', 'ios/generated/RCTAppDependencyProvider.*', 'ios/generated/RCTModuleProviders.*', 'ios/generated/RCTModulesConformingToProtocolsProvider.*', 'ios/generated/RCTUnstableModulesRequiringMainQueueSetupProvider.*' # Turbo modules require these compiler flags s.compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -DFOLLY_CFG_NO_COROUTINES=1' @@ -55,4 +57,4 @@ Pod::Spec.new do |s| else s.static_framework = false end -end +end \ No newline at end of file diff --git a/packages/functions/__tests__/functions.test.ts b/packages/functions/__tests__/functions.test.ts index f96d56bafe..2622adc180 100644 --- a/packages/functions/__tests__/functions.test.ts +++ b/packages/functions/__tests__/functions.test.ts @@ -11,6 +11,7 @@ import { type Functions, } from '../lib'; +// Import namespaced to ensure NativeRNFBTurboFunctions is registered import functions from '../lib/namespaced'; import { createCheckV9Deprecation, @@ -24,7 +25,24 @@ type FirebaseApp = ReactNativeFirebase.FirebaseApp; // @ts-ignore test import FirebaseModule from '../../app/lib/internal/FirebaseModule'; - +import { getReactNativeModule, setReactNativeModule } from '../../app/lib/internal/nativeModule'; + +// Ensure NativeRNFBTurboFunctions is registered - it should be registered by namespaced.ts +// but we verify and add removeFunctionsStreaming if needed +try { + const module = getReactNativeModule('NativeRNFBTurboFunctions'); + if (module && !module.removeFunctionsStreaming) { + module.removeFunctionsStreaming = () => {}; + } +} catch (_e) { + // Module not registered yet - register it ourselves as fallback + // This shouldn't happen if namespaced.ts imported correctly + setReactNativeModule('NativeRNFBTurboFunctions', { + httpsCallableStream: () => {}, + httpsCallableStreamFromUrl: () => {}, + removeFunctionsStreaming: () => {}, + }); +} describe('Cloud Functions', function () { describe('namespace', function () { beforeAll(async function () { @@ -37,6 +55,10 @@ describe('Cloud Functions', function () { globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = false; }); + beforeEach(function () { + // No need to mock here - RNFBFunctionsModule is already registered at module load time + }); + it('accessible from firebase.app()', function () { const app = firebase.app(); expect(app.functions).toBeDefined(); @@ -69,6 +91,28 @@ describe('Cloud Functions', function () { }); describe('httpcallable()', function () { + beforeEach(function () { + // Mock the native module streaming methods to prevent real network calls + const mockNative = { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }; + + // Override the registered native module (for web platform) + setReactNativeModule('NativeRNFBTurboFunctions', mockNative); + + // Override the native getter on FirebaseModule prototype (for native platforms) + Object.defineProperty(FirebaseModule.prototype, 'native', { + get: function (this: any) { + this._nativeModule = mockNative; + return mockNative; + }, + configurable: true, + enumerable: true, + }); + }); + it('throws an error with an incorrect timeout', function () { const app = firebase.app(); @@ -77,10 +121,86 @@ describe('Cloud Functions', function () { 'HttpsCallableOptions.timeout expected a Number in milliseconds', ); }); + + it('has stream method', function () { + const app = firebase.app(); + const callable = app.functions().httpsCallable('example'); + expect(callable.stream).toBeDefined(); + expect(typeof callable.stream).toBe('function'); + }); + + it('stream method returns unsubscribe function', function () { + const app = firebase.app(); + const callable = app.functions().httpsCallable('example'); + const unsubscribe = callable.stream({ test: 'data' }, () => {}); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + }); + }); + + describe('httpsCallableFromUrl()', function () { + beforeEach(function () { + // Mock the native module streaming methods to prevent real network calls + const mockNative = { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }; + + // Override the registered native module (for web platform) + setReactNativeModule('NativeRNFBTurboFunctions', mockNative); + + // Override the native getter on FirebaseModule prototype (for native platforms) + Object.defineProperty(FirebaseModule.prototype, 'native', { + get: function (this: any) { + this._nativeModule = mockNative; + return mockNative; + }, + configurable: true, + enumerable: true, + }); + }); + + it('has stream method', function () { + const app = firebase.app(); + const callable = app.functions().httpsCallableFromUrl('https://example.com/example'); + expect(callable.stream).toBeDefined(); + expect(typeof callable.stream).toBe('function'); + }); + + it('stream method returns unsubscribe function', function () { + const app = firebase.app(); + const callable = app.functions().httpsCallableFromUrl('https://example.com/example'); + const unsubscribe = callable.stream({ test: 'data' }, () => {}); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + }); }); }); describe('modular', function () { + beforeEach(function () { + // Mock the native module streaming methods to prevent real network calls + const mockNative = { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }; + + // Override the registered native module (for web platform) + setReactNativeModule('NativeRNFBTurboFunctions', mockNative); + + // Override the native getter on FirebaseModule prototype (for native platforms) + Object.defineProperty(FirebaseModule.prototype, 'native', { + get: function (this: any) { + this._nativeModule = mockNative; + return mockNative; + }, + configurable: true, + enumerable: true, + }); + }); + it('`getFunctions` function is properly exposed to end user', function () { expect(getFunctions).toBeDefined(); }); @@ -101,6 +221,32 @@ describe('Cloud Functions', function () { expect(HttpsErrorCode).toBeDefined(); }); + it('`httpsCallable().stream()` method is properly exposed to end user', function () { + const callable = httpsCallable(getFunctions(), 'example'); + expect(callable.stream).toBeDefined(); + expect(typeof callable.stream).toBe('function'); + }); + + it('`httpsCallableFromUrl().stream()` method is properly exposed to end user', function () { + const callable = httpsCallableFromUrl(getFunctions(), 'https://example.com/example'); + expect(callable.stream).toBeDefined(); + expect(typeof callable.stream).toBe('function'); + }); + + it('`httpsCallable().stream()` returns unsubscribe function', function () { + const callable = httpsCallable(getFunctions(), 'example'); + const unsubscribe = callable.stream({ test: 'data' }, () => {}); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + }); + + it('`httpsCallableFromUrl().stream()` returns unsubscribe function', function () { + const callable = httpsCallableFromUrl(getFunctions(), 'https://example.com/example'); + const unsubscribe = callable.stream({ test: 'data' }, () => {}); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + }); + describe('types', function () { it('`HttpsCallableOptions` type is properly exposed to end user', function () { const options: HttpsCallableOptions = { timeout: 5000 }; @@ -110,10 +256,18 @@ describe('Cloud Functions', function () { it('`HttpsCallable` type is properly exposed to end user', function () { // Type check - this will fail at compile time if type is not exported - const callable: HttpsCallableType<{ test: string }, { result: number }> = async () => { - return { data: { result: 42 } }; - }; + const callable: HttpsCallableType<{ test: string }, { result: number }> = Object.assign( + async () => { + return { data: { result: 42 } }; + }, + { + stream: (_data?: any, _onEvent?: any, _options?: any) => { + return () => {}; + }, + }, + ); expect(callable).toBeDefined(); + expect(callable.stream).toBeDefined(); }); it('`FunctionsModule` type is properly exposed to end user', function () { @@ -144,21 +298,26 @@ describe('Cloud Functions', function () { beforeEach(function () { functionsRefV9Deprecation = createCheckV9Deprecation(['functions']); - // @ts-ignore test - jest.spyOn(FirebaseModule.prototype, 'native', 'get').mockImplementation(() => { - return new Proxy( - {}, - { - get: () => - jest.fn().mockResolvedValue({ - source: 'cache', - changes: [], - documents: [], - metadata: {}, - path: 'foo', - } as never), - }, - ); + // Mock the native module directly to avoid getter caching issues + const mockNative = { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + removeFunctionsStreaming: jest.fn(), + }; + + // Override the registered native module (for web platform) + setReactNativeModule('NativeRNFBTurboFunctions', mockNative); + + // Override the native getter on FirebaseModule prototype using Object.defineProperty + // This ensures the mock is returned even if _nativeModule is cached + Object.defineProperty(FirebaseModule.prototype, 'native', { + get: function (this: any) { + // Always return the mock, clearing any cache + this._nativeModule = mockNative; + return mockNative; + }, + configurable: true, + enumerable: true, }); }); @@ -189,6 +348,31 @@ describe('Cloud Functions', function () { 'httpsCallableFromUrl', ); }); + + it('httpsCallable().stream()', function () { + const functions = (getApp() as unknown as FirebaseApp).functions(); + functionsRefV9Deprecation( + () => httpsCallable(functions, 'example').stream({ test: 'data' }, () => {}), + () => functions.httpsCallable('example').stream({ test: 'data' }, () => {}), + 'httpsCallable', + ); + }); + + it('httpsCallableFromUrl().stream()', function () { + const functions = (getApp() as unknown as FirebaseApp).functions(); + functionsRefV9Deprecation( + () => + httpsCallableFromUrl(functions, 'https://example.com/example').stream( + { test: 'data' }, + () => {}, + ), + () => + functions + .httpsCallableFromUrl('https://example.com/example') + .stream({ test: 'data' }, () => {}), + 'httpsCallableFromUrl', + ); + }); }); }); }); diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/FirebaseFunctionsStreamHandler.java b/packages/functions/android/src/main/java/io/invertase/firebase/functions/FirebaseFunctionsStreamHandler.java new file mode 100644 index 0000000000..a5ee5d5027 --- /dev/null +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/FirebaseFunctionsStreamHandler.java @@ -0,0 +1,61 @@ +package io.invertase.firebase.functions; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import io.invertase.firebase.interfaces.NativeEvent; + +public class FirebaseFunctionsStreamHandler implements NativeEvent { + static final String FUNCTIONS_STREAMING_EVENT = "functions_streaming_event"; + private static final String KEY_ID = "listenerId"; + private static final String KEY_BODY = "body"; + private static final String KEY_APP_NAME = "appName"; + private static final String KEY_EVENT_NAME = "eventName"; + private String eventName; + private WritableMap eventBody; + private String appName; + private int listenerId; + + FirebaseFunctionsStreamHandler( + String eventName, WritableMap eventBody, String appName, int listenerId) { + this.eventName = eventName; + this.eventBody = eventBody; + this.appName = appName; + this.listenerId = listenerId; + } + + @Override + public String getEventName() { + return eventName; + } + + @Override + public WritableMap getEventBody() { + WritableMap event = Arguments.createMap(); + event.putInt(KEY_ID, listenerId); + event.putMap(KEY_BODY, eventBody); + event.putString(KEY_APP_NAME, appName); + event.putString(KEY_EVENT_NAME, eventName); + return event; + } + + @Override + public String getFirebaseAppName() { + return appName; + } +} diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/NativeRNFBTurboFunctions.java b/packages/functions/android/src/main/java/io/invertase/firebase/functions/NativeRNFBTurboFunctions.java index 0e94711c53..73caed558c 100644 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/NativeRNFBTurboFunctions.java +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/NativeRNFBTurboFunctions.java @@ -17,11 +17,7 @@ * */ -import static io.invertase.firebase.functions.UniversalFirebaseFunctionsModule.CODE_KEY; -import static io.invertase.firebase.functions.UniversalFirebaseFunctionsModule.DATA_KEY; -import static io.invertase.firebase.functions.UniversalFirebaseFunctionsModule.DETAILS_KEY; -import static io.invertase.firebase.functions.UniversalFirebaseFunctionsModule.MSG_KEY; - +import android.util.SparseArray; import com.facebook.fbreact.specs.NativeRNFBTurboFunctionsSpec; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -29,21 +25,42 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.Tasks; +import com.google.firebase.FirebaseApp; +import com.google.firebase.functions.FirebaseFunctions; import com.google.firebase.functions.FirebaseFunctionsException; +import com.google.firebase.functions.HttpsCallableReference; +import com.google.firebase.functions.StreamResponse; import io.invertase.firebase.common.RCTConvertFirebase; -import io.invertase.firebase.common.UniversalFirebaseModule; +import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; +import io.invertase.firebase.common.TaskExecutorService; import java.io.IOException; +import java.net.URL; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; public class NativeRNFBTurboFunctions extends NativeRNFBTurboFunctionsSpec { private static final String SERVICE_NAME = "Functions"; - private final UniversalFirebaseFunctionsModule module; - private final UniversalFirebaseModule universalFirebaseModule; + private static final String DATA_KEY = "data"; + private static final String CODE_KEY = "code"; + private static final String MSG_KEY = "message"; + private static final String DETAILS_KEY = "details"; + private static final String STREAMING_EVENT = "functions_streaming_event"; + + private static final SparseArray functionsStreamingListeners = new SparseArray<>(); + private final TaskExecutorService executorService; public NativeRNFBTurboFunctions(ReactApplicationContext reactContext) { super(reactContext); - // cannot have multiple inheritance so we make this a property rather than extending it - universalFirebaseModule = new UniversalFirebaseModule(reactContext, SERVICE_NAME); - this.module = new UniversalFirebaseFunctionsModule(reactContext, SERVICE_NAME); + this.executorService = new TaskExecutorService("Universal" + SERVICE_NAME + "Module"); + } + + private ExecutorService getExecutor() { + return executorService.getExecutor(); } @Override @@ -58,24 +75,17 @@ public void httpsCallable( Promise promise) { Object callableData = data.toHashMap().get(DATA_KEY); - - // Convert emulatorPort to Integer (null if not using emulator) Integer port = emulatorHost != null ? (int) emulatorPort : null; Task callMethodTask = - module.httpsCallable(appName, region, emulatorHost, port, name, callableData, options); + httpsCallableInternal(appName, region, emulatorHost, port, name, null, callableData, options); - // resolve callMethodTask.addOnSuccessListener( - universalFirebaseModule.getExecutor(), - result -> { - promise.resolve(RCTConvertFirebase.mapPutValue(DATA_KEY, result, Arguments.createMap())); - }); + getExecutor(), + result -> promise.resolve(RCTConvertFirebase.mapPutValue(DATA_KEY, result, Arguments.createMap()))); - // reject callMethodTask.addOnFailureListener( - universalFirebaseModule.getExecutor(), - exception -> handleFunctionsException(exception, promise)); + getExecutor(), exception -> handleFunctionsException(exception, promise)); } @Override @@ -90,50 +100,305 @@ public void httpsCallableFromUrl( Promise promise) { Object callableData = data.toHashMap().get(DATA_KEY); - - // Convert emulatorPort to Integer (null if not using emulator) Integer port = emulatorHost != null ? (int) emulatorPort : null; Task callMethodTask = - module.httpsCallableFromUrl( - appName, region, emulatorHost, port, url, callableData, options); + httpsCallableInternal(appName, region, emulatorHost, port, null, url, callableData, options); callMethodTask.addOnSuccessListener( - universalFirebaseModule.getExecutor(), - result -> { - promise.resolve(RCTConvertFirebase.mapPutValue(DATA_KEY, result, Arguments.createMap())); - }); + getExecutor(), + result -> promise.resolve(RCTConvertFirebase.mapPutValue(DATA_KEY, result, Arguments.createMap()))); callMethodTask.addOnFailureListener( - universalFirebaseModule.getExecutor(), - exception -> handleFunctionsException(exception, promise)); + getExecutor(), exception -> handleFunctionsException(exception, promise)); + } + + @Override + public void httpsCallableStream( + String appName, + String region, + String emulatorHost, + double emulatorPort, + String name, + ReadableMap data, + ReadableMap options, + double listenerId) { + Object callableData = data.toHashMap().get(DATA_KEY); + Integer port = emulatorHost != null ? (int) emulatorPort : null; + + httpsCallableStreamSetup( + appName, region, emulatorHost, port, name, null, callableData, options, (int) listenerId); + } + + @Override + public void httpsCallableStreamFromUrl( + String appName, + String region, + String emulatorHost, + double emulatorPort, + String url, + ReadableMap data, + ReadableMap options, + double listenerId) { + + Object callableData = data.toHashMap().get(DATA_KEY); + Integer port = emulatorHost != null ? (int) emulatorPort : null; + + httpsCallableStreamSetup( + appName, region, emulatorHost, port, null, url, callableData, options, (int) listenerId); + } + + @Override + public void removeFunctionsStreaming(String appName, String region, double listenerId) { + removeFunctionsStreamingListener((int) listenerId); + } + + // Internal implementation methods + + private Task httpsCallableInternal( + String appName, + String region, + String host, + Integer port, + String name, + String url, + Object data, + ReadableMap options) { + TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); + + getExecutor() + .execute( + () -> { + try { + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseFunctions functionsInstance = + FirebaseFunctions.getInstance(firebaseApp, region); + + // Create reference based on which parameter is provided + HttpsCallableReference httpReference; + if (url != null) { + URL parsedUrl = new URL(url); + httpReference = functionsInstance.getHttpsCallableFromUrl(parsedUrl); + } else { + httpReference = functionsInstance.getHttpsCallable(name); + } + + if (options.hasKey("timeout")) { + httpReference.setTimeout(options.getInt("timeout"), TimeUnit.SECONDS); + } + + if (host != null) { + functionsInstance.useEmulator(host, port); + } + + Object result = Tasks.await(httpReference.call(data)).getData(); + taskCompletionSource.setResult(result); + } catch (Exception e) { + taskCompletionSource.setException(e); + } + }); + + return taskCompletionSource.getTask(); + } + + private void httpsCallableStreamSetup( + String appName, + String region, + String host, + Integer port, + String name, + String url, + Object data, + ReadableMap options, + int listenerId) { + getExecutor() + .execute( + () -> { + try { + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseFunctions functionsInstance = + FirebaseFunctions.getInstance(firebaseApp, region); + + if (host != null) { + functionsInstance.useEmulator(host, port); + } + + // Create reference based on which parameter is provided + HttpsCallableReference httpReference; + if (url != null) { + URL parsedUrl = new URL(url); + httpReference = functionsInstance.getHttpsCallableFromUrl(parsedUrl); + } else { + httpReference = functionsInstance.getHttpsCallable(name); + } + + if (options.hasKey("timeout")) { + httpReference.setTimeout(options.getInt("timeout"), TimeUnit.SECONDS); + } + + Publisher publisher = httpReference.stream(data); + + publisher.subscribe( + new Subscriber<>() { + + @Override + public void onSubscribe(Subscription s) { + functionsStreamingListeners.put(listenerId, s); + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(StreamResponse streamResponse) { + + Object responseData = null; + boolean isFinalResult = false; + + if (streamResponse instanceof StreamResponse.Message message) { + responseData = message.getMessage().getData(); + } else if (streamResponse instanceof StreamResponse.Result result) { + responseData = result.getResult().getData(); + isFinalResult = true; + } + + if (isFinalResult) { + emitStreamEventWithDone(appName, listenerId, responseData); + removeFunctionsStreamingListener(listenerId); + } else { + emitStreamEvent(appName, listenerId, responseData, false, null); + } + } + + @Override + public void onError(Throwable t) { + WritableMap errorMap = createErrorMap(t); + emitStreamEvent(appName, listenerId, null, true, errorMap); + removeFunctionsStreamingListener(listenerId); + } + + @Override + public void onComplete() { + Object listener = functionsStreamingListeners.get(listenerId); + if (listener != null) { + emitStreamEventWithDone(appName, listenerId, null); + removeFunctionsStreamingListener(listenerId); + } + } + }); + } catch (Exception e) { + WritableMap errorMap = createErrorMap(e); + emitStreamEvent(appName, listenerId, null, true, errorMap); + removeFunctionsStreamingListener(listenerId); + } + }); + } + + private void removeFunctionsStreamingListener(int listenerId) { + Object listener = functionsStreamingListeners.get(listenerId); + if (listener != null) { + if (listener instanceof Subscription) { + ((Subscription) listener).cancel(); + } + functionsStreamingListeners.remove(listenerId); + } + } + + private void emitStreamEvent( + String appName, int listenerId, Object data, boolean isError, WritableMap errorMap) { + WritableMap body = Arguments.createMap(); + + if (isError) { + body.putMap("error", errorMap); + body.putBoolean("done", true); + } else { + body.putBoolean("done", false); + if (data != null) { + RCTConvertFirebase.mapPutValue("data", data, body); + } + } + + FirebaseFunctionsStreamHandler handler = + new FirebaseFunctionsStreamHandler(STREAMING_EVENT, body, appName, listenerId); + + ReactNativeFirebaseEventEmitter.getSharedInstance().sendEvent(handler); + } + + private void emitStreamEventWithDone(String appName, int listenerId, Object data) { + WritableMap body = Arguments.createMap(); + body.putBoolean("done", true); + + if (data != null) { + RCTConvertFirebase.mapPutValue("data", data, body); + } + + FirebaseFunctionsStreamHandler handler = + new FirebaseFunctionsStreamHandler(STREAMING_EVENT, body, appName, listenerId); + + ReactNativeFirebaseEventEmitter.getSharedInstance().sendEvent(handler); } private void handleFunctionsException(Exception exception, Promise promise) { + WritableMap errorMap = createErrorMap(exception); + + String code = errorMap.getString(CODE_KEY); + String message = errorMap.getString(MSG_KEY); + + promise.reject(code, message, exception, errorMap); + } + + private WritableMap createErrorMap(Throwable throwable) { Object details = null; String code = "UNKNOWN"; - String message = exception.getMessage(); - WritableMap userInfo = Arguments.createMap(); + String message = throwable.getMessage() != null ? throwable.getMessage() : throwable.toString(); - if (exception.getCause() != null) { - FirebaseFunctionsException functionsException = - (FirebaseFunctionsException) exception.getCause(); + // Check if the throwable contains a FirebaseFunctionsException + if (throwable.getCause() != null && throwable.getCause() instanceof FirebaseFunctionsException) { + FirebaseFunctionsException functionsException = (FirebaseFunctionsException) throwable.getCause(); details = functionsException.getDetails(); code = functionsException.getCode().name(); message = functionsException.getMessage(); String timeout = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name(); - Boolean isTimeout = code.contains(timeout); + boolean isTimeout = code.contains(timeout); if (functionsException.getCause() instanceof IOException && !isTimeout) { - // return UNAVAILABLE for network io errors, to match iOS code = FirebaseFunctionsException.Code.UNAVAILABLE.name(); message = FirebaseFunctionsException.Code.UNAVAILABLE.name(); } + } else if (throwable instanceof FirebaseFunctionsException) { + FirebaseFunctionsException functionsException = (FirebaseFunctionsException) throwable; + details = functionsException.getDetails(); + code = functionsException.getCode().name(); + message = functionsException.getMessage(); + String timeout = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name(); + boolean isTimeout = code.contains(timeout); + + if (functionsException.getCause() instanceof IOException && !isTimeout) { + code = FirebaseFunctionsException.Code.UNAVAILABLE.name(); + message = FirebaseFunctionsException.Code.UNAVAILABLE.name(); + } + } + + WritableMap errorMap = Arguments.createMap(); + errorMap.putString(CODE_KEY, code); + errorMap.putString(MSG_KEY, message); + RCTConvertFirebase.mapPutValue(DETAILS_KEY, details, errorMap); + + return errorMap; + } + + @Override + public void invalidate() { + super.invalidate(); + + // Cancel all active streaming listeners before shutdown + for (int i = 0; i < functionsStreamingListeners.size(); i++) { + int listenerId = functionsStreamingListeners.keyAt(i); + Object listener = functionsStreamingListeners.get(listenerId); + if (listener instanceof Subscription) { + ((Subscription) listener).cancel(); + } } + functionsStreamingListeners.clear(); - RCTConvertFirebase.mapPutValue(CODE_KEY, code, userInfo); - RCTConvertFirebase.mapPutValue(MSG_KEY, message, userInfo); - RCTConvertFirebase.mapPutValue(DETAILS_KEY, details, userInfo); - promise.reject(code, message, exception, userInfo); + executorService.shutdown(); } } diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/UniversalFirebaseFunctionsModule.java b/packages/functions/android/src/main/java/io/invertase/firebase/functions/UniversalFirebaseFunctionsModule.java deleted file mode 100644 index af692704b8..0000000000 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/UniversalFirebaseFunctionsModule.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.invertase.firebase.functions; - -/* - * Copyright (c) 2016-present Invertase Limited & Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this library except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -import android.content.Context; -import com.facebook.react.bridge.ReadableMap; -import com.google.android.gms.tasks.Task; -import com.google.android.gms.tasks.Tasks; -import com.google.firebase.FirebaseApp; -import com.google.firebase.functions.FirebaseFunctions; -import com.google.firebase.functions.HttpsCallableReference; -import io.invertase.firebase.common.UniversalFirebaseModule; -import java.net.URL; -import java.util.concurrent.TimeUnit; - -@SuppressWarnings("WeakerAccess") -public class UniversalFirebaseFunctionsModule extends UniversalFirebaseModule { - public static final String DATA_KEY = "data"; - public static final String CODE_KEY = "code"; - public static final String MSG_KEY = "message"; - public static final String DETAILS_KEY = "details"; - - UniversalFirebaseFunctionsModule(Context context, String serviceName) { - super(context, serviceName); - } - - Task httpsCallable( - String appName, - String region, - String host, - Integer port, - String name, - Object data, - ReadableMap options) { - return Tasks.call( - getExecutor(), - () -> { - FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); - FirebaseFunctions functionsInstance = FirebaseFunctions.getInstance(firebaseApp, region); - - HttpsCallableReference httpReference = functionsInstance.getHttpsCallable(name); - - if (options.hasKey("timeout")) { - httpReference.setTimeout((long) options.getInt("timeout"), TimeUnit.SECONDS); - } - - if (host != null) { - functionsInstance.useEmulator(host, port); - } - - return Tasks.await(httpReference.call(data)).getData(); - }); - } - - Task httpsCallableFromUrl( - String appName, - String region, - String host, - Integer port, - String url, - Object data, - ReadableMap options) { - return Tasks.call( - getExecutor(), - () -> { - FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); - FirebaseFunctions functionsInstance = FirebaseFunctions.getInstance(firebaseApp, region); - URL parsedUrl = new URL(url); - HttpsCallableReference httpReference = - functionsInstance.getHttpsCallableFromUrl(parsedUrl); - - if (options.hasKey("timeout")) { - httpReference.setTimeout((long) options.getInt("timeout"), TimeUnit.SECONDS); - } - - if (host != null) { - functionsInstance.useEmulator(host, port); - } - - return Tasks.await(httpReference.call(data)).getData(); - }); - } -} diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboFunctionsSpec.java b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboFunctionsSpec.java index 7784a71ade..a2c5c2109c 100644 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboFunctionsSpec.java +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboFunctionsSpec.java @@ -41,4 +41,16 @@ public NativeRNFBTurboFunctionsSpec(ReactApplicationContext reactContext) { @ReactMethod @DoNotStrip public abstract void httpsCallableFromUrl(String appName, String region, @Nullable String emulatorHost, double emulatorPort, String url, ReadableMap data, ReadableMap options, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void httpsCallableStream(String appName, String region, @Nullable String emulatorHost, double emulatorPort, String name, ReadableMap data, ReadableMap options, double listenerId); + + @ReactMethod + @DoNotStrip + public abstract void httpsCallableStreamFromUrl(String appName, String region, @Nullable String emulatorHost, double emulatorPort, String url, ReadableMap data, ReadableMap options, double listenerId); + + @ReactMethod + @DoNotStrip + public abstract void removeFunctionsStreaming(String appName, String region, double listenerId); } diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/NativeRNFBTurboFunctions-generated.cpp b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/NativeRNFBTurboFunctions-generated.cpp index 2c35a4c6fe..63263ee488 100644 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/NativeRNFBTurboFunctions-generated.cpp +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/NativeRNFBTurboFunctions-generated.cpp @@ -22,10 +22,28 @@ static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_https return static_cast(turboModule).invokeJavaMethod(rt, PromiseKind, "httpsCallableFromUrl", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId); } +static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStream(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + static jmethodID cachedMethodId = nullptr; + return static_cast(turboModule).invokeJavaMethod(rt, VoidKind, "httpsCallableStream", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/ReadableMap;D)V", args, count, cachedMethodId); +} + +static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStreamFromUrl(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + static jmethodID cachedMethodId = nullptr; + return static_cast(turboModule).invokeJavaMethod(rt, VoidKind, "httpsCallableStreamFromUrl", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/ReadableMap;D)V", args, count, cachedMethodId); +} + +static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_removeFunctionsStreaming(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + static jmethodID cachedMethodId = nullptr; + return static_cast(turboModule).invokeJavaMethod(rt, VoidKind, "removeFunctionsStreaming", "(Ljava/lang/String;Ljava/lang/String;D)V", args, count, cachedMethodId); +} + NativeRNFBTurboFunctionsSpecJSI::NativeRNFBTurboFunctionsSpecJSI(const JavaTurboModule::InitParams ¶ms) : JavaTurboModule(params) { methodMap_["httpsCallable"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallable}; methodMap_["httpsCallableFromUrl"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableFromUrl}; + methodMap_["httpsCallableStream"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStream}; + methodMap_["httpsCallableStreamFromUrl"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStreamFromUrl}; + methodMap_["removeFunctionsStreaming"] = MethodMetadata {3, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_removeFunctionsStreaming}; } std::shared_ptr NativeRNFBTurboFunctions_ModuleProvider(const std::string &moduleName, const JavaTurboModule::InitParams ¶ms) { diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI-generated.cpp b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI-generated.cpp index c892ffbf8b..31cddefdf8 100644 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI-generated.cpp +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI-generated.cpp @@ -35,11 +35,51 @@ static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallabl count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt) ); } +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStream(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->httpsCallableStream( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 || args[2].isNull() || args[2].isUndefined() ? std::nullopt : std::make_optional(args[2].asString(rt)), + count <= 3 ? throw jsi::JSError(rt, "Expected argument in position 3 to be passed") : args[3].asNumber(), + count <= 4 ? throw jsi::JSError(rt, "Expected argument in position 4 to be passed") : args[4].asString(rt), + count <= 5 ? throw jsi::JSError(rt, "Expected argument in position 5 to be passed") : args[5].asObject(rt), + count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt), + count <= 7 ? throw jsi::JSError(rt, "Expected argument in position 7 to be passed") : args[7].asNumber() + ); + return jsi::Value::undefined(); +} +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStreamFromUrl(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->httpsCallableStreamFromUrl( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 || args[2].isNull() || args[2].isUndefined() ? std::nullopt : std::make_optional(args[2].asString(rt)), + count <= 3 ? throw jsi::JSError(rt, "Expected argument in position 3 to be passed") : args[3].asNumber(), + count <= 4 ? throw jsi::JSError(rt, "Expected argument in position 4 to be passed") : args[4].asString(rt), + count <= 5 ? throw jsi::JSError(rt, "Expected argument in position 5 to be passed") : args[5].asObject(rt), + count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt), + count <= 7 ? throw jsi::JSError(rt, "Expected argument in position 7 to be passed") : args[7].asNumber() + ); + return jsi::Value::undefined(); +} +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_removeFunctionsStreaming(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->removeFunctionsStreaming( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 ? throw jsi::JSError(rt, "Expected argument in position 2 to be passed") : args[2].asNumber() + ); + return jsi::Value::undefined(); +} NativeRNFBTurboFunctionsCxxSpecJSI::NativeRNFBTurboFunctionsCxxSpecJSI(std::shared_ptr jsInvoker) : TurboModule("NativeRNFBTurboFunctions", jsInvoker) { methodMap_["httpsCallable"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallable}; methodMap_["httpsCallableFromUrl"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableFromUrl}; + methodMap_["httpsCallableStream"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStream}; + methodMap_["httpsCallableStreamFromUrl"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStreamFromUrl}; + methodMap_["removeFunctionsStreaming"] = MethodMetadata {3, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_removeFunctionsStreaming}; } diff --git a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI.h b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI.h index 2cd49cbaee..4e9f4594a7 100644 --- a/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI.h +++ b/packages/functions/android/src/main/java/io/invertase/firebase/functions/generated/jni/react/renderer/components/NativeRNFBTurboFunctions/NativeRNFBTurboFunctionsJSI.h @@ -22,6 +22,9 @@ namespace facebook::react { public: virtual jsi::Value httpsCallable(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options) = 0; virtual jsi::Value httpsCallableFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options) = 0; + virtual void httpsCallableStream(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options, double listenerId) = 0; + virtual void httpsCallableStreamFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options, double listenerId) = 0; + virtual void removeFunctionsStreaming(jsi::Runtime &rt, jsi::String appName, jsi::String region, double listenerId) = 0; }; @@ -68,6 +71,30 @@ class JSI_EXPORT NativeRNFBTurboFunctionsCxxSpec : public TurboModule { return bridging::callFromJs( rt, &T::httpsCallableFromUrl, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(url), std::move(data), std::move(options)); } + void httpsCallableStream(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::httpsCallableStream) == 9, + "Expected httpsCallableStream(...) to have 9 parameters"); + + return bridging::callFromJs( + rt, &T::httpsCallableStream, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(name), std::move(data), std::move(options), std::move(listenerId)); + } + void httpsCallableStreamFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::httpsCallableStreamFromUrl) == 9, + "Expected httpsCallableStreamFromUrl(...) to have 9 parameters"); + + return bridging::callFromJs( + rt, &T::httpsCallableStreamFromUrl, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(url), std::move(data), std::move(options), std::move(listenerId)); + } + void removeFunctionsStreaming(jsi::Runtime &rt, jsi::String appName, jsi::String region, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::removeFunctionsStreaming) == 4, + "Expected removeFunctionsStreaming(...) to have 4 parameters"); + + return bridging::callFromJs( + rt, &T::removeFunctionsStreaming, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(listenerId)); + } private: friend class NativeRNFBTurboFunctionsCxxSpec; diff --git a/packages/functions/e2e/functions.e2e.js b/packages/functions/e2e/functions.e2e.js index f2f81a1d1e..68953b85d4 100644 --- a/packages/functions/e2e/functions.e2e.js +++ b/packages/functions/e2e/functions.e2e.js @@ -412,6 +412,124 @@ describe('functions() modular', function () { } }); }); + + describe('httpsCallable.stream()', function () { + // NOTE: The Firebase Functions emulator does not currently support streaming callables, + // even though the SDK APIs exist. These tests verify the API surface exists and will + // be updated to test actual streaming behavior once emulator support is added. + // See: packages/functions/STREAMING_STATUS.md + + it('stream method exists on httpsCallable', function () { + const functionRunner = firebase.functions().httpsCallable('testStreamingCallable'); + + should.exist(functionRunner.stream); + functionRunner.stream.should.be.a.Function(); + }); + + it('stream method returns a function (unsubscribe)', function () { + const functions = firebase.functions(); + functions.useEmulator('localhost', 5001); + const functionRunner = functions.httpsCallable('testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}); + + should.exist(unsubscribe); + unsubscribe.should.be.a.Function(); + + // Clean up + unsubscribe(); + }); + + it('unsubscribe function can be called multiple times safely', function () { + const functions = firebase.functions(); + functions.useEmulator('localhost', 5001); + const functionRunner = functions.httpsCallable('testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}); + + // Should not throw + unsubscribe(); + unsubscribe(); + unsubscribe(); + }); + + it('stream method accepts data and callback parameters', function () { + const functions = firebase.functions(); + functions.useEmulator('localhost', 5001); + const functionRunner = functions.httpsCallable('testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 2, delay: 100 }, _event => { + // Callback will be invoked when streaming works + }); + + should.exist(unsubscribe); + unsubscribe(); + }); + + it('stream method accepts options parameter', function () { + const functions = firebase.functions(); + functions.useEmulator('localhost', 5001); + const functionRunner = functions.httpsCallable('testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}, { timeout: 5000 }); + + should.exist(unsubscribe); + unsubscribe(); + }); + + it('receives streaming data chunks', function (done) { + const functions = firebase.functions(); + functions.useEmulator('localhost', 5001); + const events = []; + const functionRunner = functions.httpsCallable('testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 3, delay: 200 }, event => { + events.push(event); + + if (event.done) { + try { + events.length.should.be.greaterThan(1); + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + // Verify we actually received data in the chunks + dataEvents.forEach(dataEvent => { + should.exist(dataEvent.data); + dataEvent.data.should.be.an.Object(); + should.exist(dataEvent.data.index); + should.exist(dataEvent.data.message); + }); + const doneEvent = events[events.length - 1]; + doneEvent.done.should.equal(true); + // Final result should also have data + should.exist(doneEvent.data); + doneEvent.data.should.be.an.Object(); + should.exist(doneEvent.data.totalCount); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('stream method exists on httpsCallableFromUrl', function () { + let hostname = 'localhost'; + if (Platform.android) { + hostname = '10.0.2.2'; + } + + const functionRunner = firebase + .functions() + .httpsCallableFromUrl( + `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + ); + + should.exist(functionRunner.stream); + functionRunner.stream.should.be.a.Function(); + }); + }); }); describe('modular', function () { @@ -774,5 +892,531 @@ describe('functions() modular', function () { } }); }); + + describe('httpsCallable.stream()', function () { + // NOTE: The Firebase Functions emulator does not currently support streaming callables, + // even though the SDK APIs exist. These tests verify the API surface exists and will + // be updated to test actual streaming behavior once emulator support is added. + // See: packages/functions/STREAMING_STATUS.md + + it('stream method exists on httpsCallable', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable } = functionsModular; + const functions = getFunctions(getApp()); + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + + should.exist(functionRunner.stream); + functionRunner.stream.should.be.a.Function(); + }); + + it('stream method returns a function (unsubscribe)', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}); + + should.exist(unsubscribe); + unsubscribe.should.be.a.Function(); + + // Clean up + unsubscribe(); + }); + + it('unsubscribe function can be called multiple times safely', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}); + + // Should not throw + unsubscribe(); + unsubscribe(); + unsubscribe(); + }); + + it('stream method accepts data and callback parameters', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + let _callbackInvoked = false; + + const unsubscribe = functionRunner.stream({ count: 2, delay: 100 }, _event => { + _callbackInvoked = true; + }); + + should.exist(unsubscribe); + unsubscribe(); + }); + + it('stream method accepts options parameter', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + const unsubscribe = functionRunner.stream({ count: 2 }, () => {}, { timeout: 5000 }); + + should.exist(unsubscribe); + unsubscribe(); + }); + + it('receives streaming data chunks', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const events = []; + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 3, delay: 200 }, event => { + events.push(event); + + if (event.done) { + try { + // Should have received data events before done + events.length.should.be.greaterThan(1); + + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + + // Verify we actually received data in the chunks + dataEvents.forEach(dataEvent => { + should.exist(dataEvent.data); + dataEvent.data.should.be.an.Object(); + should.exist(dataEvent.data.index); + should.exist(dataEvent.data.message); + }); + + const doneEvent = events[events.length - 1]; + doneEvent.done.should.equal(true); + // Final result should also have data + should.exist(doneEvent.data); + doneEvent.data.should.be.an.Object(); + should.exist(doneEvent.data.totalCount); + + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('handles streaming errors correctly', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamWithError'); + + const unsubscribe = functionRunner.stream({ failAfter: 2 }, event => { + if (event.error) { + try { + should.exist(event.error); + event.error.should.be.a.String(); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('cancels stream when unsubscribe is called', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const events = []; + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + + const unsubscribe = functionRunner.stream({ count: 10, delay: 200 }, event => { + events.push(event); + + // Cancel after first event + if (events.length === 1) { + unsubscribe(); + // Wait a bit to ensure no more events arrive + setTimeout(() => { + try { + // Should not have received all 10 events + events.length.should.be.lessThan(10); + done(); + } catch (e) { + done(e); + } + }, 1000); + } + }); + }); + + it('stream method exists on httpsCallableFromUrl', function () { + const { getApp } = modular; + const { getFunctions, httpsCallableFromUrl } = functionsModular; + const functions = getFunctions(getApp()); + + let hostname = 'localhost'; + if (Platform.android) { + hostname = '10.0.2.2'; + } + + const functionRunner = httpsCallableFromUrl( + functions, + `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + ); + + should.exist(functionRunner.stream); + functionRunner.stream.should.be.a.Function(); + }); + + it('httpsCallableFromUrl can stream data', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallableFromUrl, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + let hostname = 'localhost'; + if (Platform.android) { + hostname = '10.0.2.2'; + } + + const events = []; + const functionRunner = httpsCallableFromUrl( + functions, + `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + ); + + const unsubscribe = functionRunner.stream({ count: 3, delay: 200 }, event => { + events.push(event); + + if (event.done) { + try { + events.length.should.be.greaterThan(1); + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + // Verify we actually received data in the chunks + dataEvents.forEach(dataEvent => { + should.exist(dataEvent.data); + dataEvent.data.should.be.an.Object(); + should.exist(dataEvent.data.index); + should.exist(dataEvent.data.message); + }); + // Final result should also have data + const doneEvent = events[events.length - 1]; + should.exist(doneEvent.data); + doneEvent.data.should.be.an.Object(); + should.exist(doneEvent.data.totalCount); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('stream handles complex data structures', function () { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testComplexDataStream'); + const complexData = { + nested: { value: 123 }, + array: [1, 2, 3], + string: 'test', + }; + + const unsubscribe = functionRunner.stream(complexData, () => {}); + + should.exist(unsubscribe); + unsubscribe(); + }); + + it('receives streaming data with string value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream('foo', event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('string'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with number value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream(123, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('number'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with boolean true value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream(true, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('boolean'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with boolean false value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream(false, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('boolean'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with null value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream(null, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('null'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with no arguments', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + + const unsubscribe = functionRunner.stream(undefined, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('null'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with array value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + const testArray = [1, 2, 3, 'test', true]; + + const unsubscribe = functionRunner.stream(testArray, event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + dataEvents[0].data.partialData.should.equal('array'); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + it('receives streaming data with deeply nested map', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamResponse'); + const events = []; + const deepMap = { + number: 123, + string: 'foo', + booleanTrue: true, + booleanFalse: false, + null: null, + list: ['1', 2, true, false], + map: { + number: 123, + string: 'foo', + booleanTrue: true, + booleanFalse: false, + null: null, + }, + }; + + const unsubscribe = functionRunner.stream( + { type: 'deepMap', inputData: deepMap }, + event => { + events.push(event); + + if (event.done) { + try { + const dataEvents = events.filter(e => e.data && !e.done); + dataEvents.length.should.be.greaterThan(0); + // Verify we received the deep map data + should.exist(dataEvents[0].data.partialData); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }, + ); + }); + + it('should emit a result as last value', function (done) { + const { getApp } = modular; + const { getFunctions, httpsCallable, connectFunctionsEmulator } = functionsModular; + const functions = getFunctions(getApp()); + connectFunctionsEmulator(functions, 'localhost', 5001); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + const events = []; + + const unsubscribe = functionRunner.stream({ count: 2, delay: 100 }, event => { + events.push(event); + + if (event.done) { + try { + // Last event should be the final result + const lastEvent = events[events.length - 1]; + lastEvent.done.should.equal(true); + should.exist(lastEvent.data); + lastEvent.data.should.be.an.Object(); + should.exist(lastEvent.data.totalCount); + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + }); }); }); diff --git a/packages/functions/ios/Podfile.helper.rb b/packages/functions/ios/Podfile.helper.rb new file mode 100644 index 0000000000..2ef01be2bf --- /dev/null +++ b/packages/functions/ios/Podfile.helper.rb @@ -0,0 +1,48 @@ +# Helper file for RNFBFunctions Swift file integration +# Add this to your Podfile: require_relative 'node_modules/@react-native-firebase/functions/ios/Podfile.helper.rb' +# Or if using a monorepo: require_relative '../packages/functions/ios/Podfile.helper.rb' + +def add_rnfb_functions_swift_files(installer) + installer.pods_project.targets.each do |target| + if target.name == 'RNFBFunctions' + # Find Swift files in the pod directory + pod_path = File.join(installer.sandbox.root, 'RNFBFunctions') + next unless File.exist?(pod_path) + + swift_files = Dir.glob(File.join(pod_path, 'ios', '**', '*.swift')) + + swift_files.each do |swift_file| + # Get relative path from pod root (e.g., "ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift") + relative_path = Pathname.new(swift_file).relative_path_from(Pathname.new(pod_path)).to_s + + # Find or create the file reference in the project + group_path = File.dirname(relative_path) + file_name = File.basename(relative_path) + + # Navigate to the group (create if needed) + group = target.project.main_group + group_path.split('/').each do |segment| + next if segment == '.' || segment.empty? + group = group[segment] || group.new_group(segment) + end + + # Find or create file reference + file_ref = group.files.find { |f| f.path == file_name } + unless file_ref + file_ref = group.new_file(file_name) + end + + # Add to target's source build phase if not already added + unless target.source_build_phase.files.find { |f| f.file_ref == file_ref } + target.add_file_references([file_ref]) + end + end + + # Ensure Swift compilation settings + target.build_configurations.each do |config| + config.build_settings['SWIFT_COMPILATION_MODE'] = 'wholemodule' + end + end + end +end + diff --git a/packages/functions/ios/RNFBFunctions.xcodeproj/project.pbxproj b/packages/functions/ios/RNFBFunctions.xcodeproj/project.pbxproj index c80c7e410f..5740243a4c 100644 --- a/packages/functions/ios/RNFBFunctions.xcodeproj/project.pbxproj +++ b/packages/functions/ios/RNFBFunctions.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 2744B98621F45429004F8E3F /* RNFBFunctionsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 2744B98521F45429004F8E3F /* RNFBFunctionsModule.m */; }; + 2744B98621F45429004F8E3F /* RNFBFunctionsModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 2744B98521F45429004F8E3F /* RNFBFunctionsModule.mm */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -25,7 +25,7 @@ /* Begin PBXFileReference section */ 2744B98221F45429004F8E3F /* libRNFBFunctions.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNFBFunctions.a; sourceTree = BUILT_PRODUCTS_DIR; }; 2744B98421F45429004F8E3F /* RNFBFunctionsModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNFBFunctionsModule.h; path = RNFBFunctions/RNFBFunctionsModule.h; sourceTree = SOURCE_ROOT; }; - 2744B98521F45429004F8E3F /* RNFBFunctionsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = RNFBFunctionsModule.m; path = RNFBFunctions/RNFBFunctionsModule.m; sourceTree = SOURCE_ROOT; }; + 2744B98521F45429004F8E3F /* RNFBFunctionsModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RNFBFunctionsModule.mm; path = RNFBFunctions/RNFBFunctionsModule.mm; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -53,7 +53,7 @@ 2744B9A121F48736004F8E3F /* converters */, 2744B98C21F45C64004F8E3F /* common */, 2744B98421F45429004F8E3F /* RNFBFunctionsModule.h */, - 2744B98521F45429004F8E3F /* RNFBFunctionsModule.m */, + 2744B98521F45429004F8E3F /* RNFBFunctionsModule.mm */, ); path = RNFBFunctions; sourceTree = ""; @@ -124,7 +124,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2744B98621F45429004F8E3F /* RNFBFunctionsModule.m in Sources */, + 2744B98621F45429004F8E3F /* RNFBFunctionsModule.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm b/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm index 6f09b7318e..f7d0168840 100644 --- a/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm +++ b/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm @@ -18,19 +18,46 @@ #import #import +#import #import "NativeRNFBTurboFunctions.h" #import "RNFBApp/RCTConvert+FIRApp.h" +#import "RNFBApp/RNFBRCTEventEmitter.h" #import "RNFBApp/RNFBSharedUtils.h" #import "RNFBFunctionsModule.h" -@interface RNFBFunctionsModule () -@end +static __strong NSMutableDictionary *streamListeners; @implementation RNFBFunctionsModule #pragma mark - #pragma mark Module Setup RCT_EXPORT_MODULE(NativeRNFBTurboFunctions) + +- (instancetype)init { + self = [super init]; + if (self) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + streamListeners = [[NSMutableDictionary alloc] init]; + }); + } + return self; +} + +- (void)dealloc { + [self invalidate]; +} + +- (void)invalidate { + for (NSString *key in [streamListeners allKeys]) { + id handler = streamListeners[key]; + if (handler && [handler respondsToSelector:@selector(cancel)]) { + [handler cancel]; + } + [streamListeners removeObjectForKey:key]; + } +} + #pragma mark - #pragma mark Firebase Functions Methods @@ -168,6 +195,151 @@ - (void)httpsCallableFromUrl:(NSString *)appName }]; } +#pragma mark - +#pragma mark Firebase Functions Streaming Methods + +- (void)httpsCallableStream:(NSString *)appName + region:(NSString *)customUrlOrRegion + emulatorHost:(NSString *_Nullable)emulatorHost + emulatorPort:(double)emulatorPort + name:(NSString *)name + data:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamData &)data + options:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamOptions &)options + listenerId:(double)listenerId { + [self streamSetup:appName + region:customUrlOrRegion + emulatorHost:emulatorHost + emulatorPort:emulatorPort + name:name + url:nil + data:data.data() + timeout:options.timeout() + listenerId:listenerId]; +} + +- (void) + httpsCallableStreamFromUrl:(NSString *)appName + region:(NSString *)customUrlOrRegion + emulatorHost:(NSString *_Nullable)emulatorHost + emulatorPort:(double)emulatorPort + url:(NSString *)url + data:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlData &) + data + options: + (JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlOptions &) + options + listenerId:(double)listenerId { + [self streamSetup:appName + region:customUrlOrRegion + emulatorHost:emulatorHost + emulatorPort:emulatorPort + name:nil + url:url + data:data.data() + timeout:options.timeout() + listenerId:listenerId]; +} + +- (void)streamSetup:(NSString *)appName + region:(NSString *)customUrlOrRegion + emulatorHost:(NSString *_Nullable)emulatorHost + emulatorPort:(double)emulatorPort + name:(NSString *_Nullable)name + url:(NSString *_Nullable)url + data:(id)data + timeout:(std::optional)timeout + listenerId:(double)listenerId { + NSNumber *listenerIdNumber = @((int)listenerId); + + if (@available(iOS 15.0, macOS 12.0, *)) { + NSURL *customUrl = [NSURL URLWithString:customUrlOrRegion]; + FIRApp *firebaseApp = [RCTConvert firAppFromString:appName]; + + FIRFunctions *functions = + (customUrl && customUrl.scheme && customUrl.host) + ? [FIRFunctions functionsForApp:firebaseApp customDomain:customUrlOrRegion] + : [FIRFunctions functionsForApp:firebaseApp region:customUrlOrRegion]; + + if (data == nil) { + data = [NSNull null]; + } + + if (emulatorHost != nil) { + [functions useEmulatorWithHost:emulatorHost port:(int)emulatorPort]; + } + + RNFBFunctionsStreamHandler *handler = [[RNFBFunctionsStreamHandler alloc] init]; + + double timeoutValue = timeout.has_value() ? timeout.value() : 0; + + void (^eventCallback)(NSDictionary *) = ^(NSDictionary *event) { + NSMutableDictionary *normalisedEvent = @{ + @"appName" : appName, + @"eventName" : @"functions_streaming_event", + @"listenerId" : listenerIdNumber, + @"body" : event + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" + body:normalisedEvent]; + + // Remove handler when done + if ([event[@"done"] boolValue]) { + [streamListeners removeObjectForKey:listenerIdNumber]; + } + }; + + // Call based on whether url or name is provided + if (url != nil) { + [handler startStreamWithApp:firebaseApp + functions:functions + functionUrl:url + parameters:data + timeout:timeoutValue + eventCallback:eventCallback]; + } else { + [handler startStreamWithApp:firebaseApp + functions:functions + functionName:name + parameters:data + timeout:timeoutValue + eventCallback:eventCallback]; + } + + streamListeners[listenerIdNumber] = handler; + } else { + NSDictionary *eventBody = @{ + @"appName" : appName, + @"eventName" : @"functions_streaming_event", + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : @{ + @"code" : @"cancelled", + @"message" : @"callable streams require minimum iOS 15 or macOS 12", + @"details" : [NSNull null] + }, + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" body:eventBody]; + } +} + +- (void)removeFunctionsStreaming:(NSString *)appName + region:(NSString *)region + listenerId:(double)listenerId { + NSNumber *listenerIdNumber = @((int)listenerId); + id handler = streamListeners[listenerIdNumber]; + if (handler) { + [handler cancel]; + } + + [streamListeners removeObjectForKey:listenerIdNumber]; +} + +#pragma mark - +#pragma mark Firebase Functions Helper Methods + - (NSString *)getErrorCodeName:(NSError *)error { NSString *code = @"UNKNOWN"; diff --git a/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift b/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift new file mode 100644 index 0000000000..7e0a653d0a --- /dev/null +++ b/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift @@ -0,0 +1,271 @@ +/** + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import Foundation +import FirebaseFunctions +import FirebaseCore + +/// Swift wrapper for Firebase Functions streaming that's accessible from Objective-C +/// This is necessary because Firebase's streaming API uses Swift's AsyncStream which +/// doesn't have Objective-C bridging +@available(iOS 15.0, macOS 12.0, *) +@objcMembers public class RNFBFunctionsStreamHandler: NSObject { + private var streamTask: Task? + + /// Start streaming from a Firebase Function + /// - Parameters: + /// - app: Firebase App instance (can be FIRApp or FirebaseApp) + /// - regionOrCustomDomain: Region string or custom domain URL + /// - emulatorHost: Emulator host (optional) + /// - emulatorPort: Emulator port (optional) + /// - functionName: Name of the function (mutually exclusive with functionUrl) + /// - functionUrl: URL of the function (mutually exclusive with functionName) + /// - parameters: Data to pass to the function + /// - timeout: Timeout in milliseconds + /// - eventCallback: Callback for each stream event + @objc public func startStream( + app: FirebaseApp, + functions: Functions, + functionName: String, + parameters: Any?, + timeout: Double, + eventCallback: @escaping ([AnyHashable: Any]) -> Void + ) { + streamTask = Task { + let callable: Callable> = functions.httpsCallable(functionName) + await self.performStream( + functions: functions, + callable: callable, + parameters: parameters, + timeout: timeout, + eventCallback: eventCallback + ) + } + } + + @objc public func startStream( + app: FirebaseApp, + functions: Functions, + functionUrl: String, + parameters: Any?, + timeout: Double, + eventCallback: @escaping ([AnyHashable: Any]) -> Void + ) { + let url = URL(string: functionUrl)! + + streamTask = Task { + let callable: Callable> = functions.httpsCallable(url) + + await self.performStream( + functions: functions, + callable: callable, + parameters: parameters, + timeout: timeout, + eventCallback: eventCallback + ) + } + } + + /// Cancel the streaming task + @objc public func cancel() { + streamTask?.cancel() + streamTask = nil + } + + /// Get error code name from NSError + private func getErrorCodeName(_ error: Error) -> String { + let nsError = error as NSError + var code = "UNKNOWN" + + if nsError.domain == "com.firebase.functions" { + switch nsError.code { + case 0: code = "OK" + case 1: code = "CANCELLED" + case 2: code = "UNKNOWN" + case 3: code = "INVALID_ARGUMENT" + case 4: code = "DEADLINE_EXCEEDED" + case 5: code = "NOT_FOUND" + case 6: code = "ALREADY_EXISTS" + case 7: code = "PERMISSION_DENIED" + case 8: code = "RESOURCE_EXHAUSTED" + case 9: code = "FAILED_PRECONDITION" + case 10: code = "ABORTED" + case 11: code = "OUT_OF_RANGE" + case 12: code = "UNIMPLEMENTED" + case 13: code = "INTERNAL" + case 14: code = "UNAVAILABLE" + case 15: code = "DATA_LOSS" + case 16: code = "UNAUTHENTICATED" + default: break + } + } + + if nsError.domain == "FirebaseFunctions.FunctionsSerializer.Error" { + let errorDescription = nsError.description + if errorDescription.contains("unsupportedType") { + code = "UNSUPPORTED_TYPE" + } + if errorDescription.contains("failedToParseWrappedNumber") { + code = "FAILED_TO_PARSE_WRAPPED_NUMBER" + } + } + + return code + } + + private func performStream( + functions: Functions, + callable: Callable>, + parameters: Any?, + timeout: Double, + eventCallback: @escaping ([AnyHashable: Any]) -> Void + ) async { + // We need to make var so we can set timeout on the callable + var callableStream = callable + if timeout > 0 { + callableStream.timeoutInterval = timeout + } + + do { + let encodedParams = AnyEncodable(parameters) + + let stream = try callableStream.stream(encodedParams) + + for try await response in stream { + switch response { + case .message(let message): + eventCallback([ + "data": message.value ?? NSNull(), + "error": NSNull(), + "done": false + ]) + case .result(let result): + eventCallback([ + "data": result.value ?? NSNull(), + "error": NSNull(), + "done": true + ]) + } + } + } catch { + // Check if the stream was cancelled + if error is CancellationError { + let errorDict: [String: Any] = [ + // Same code/message as in firestore + "code": "cancelled", + "message": "The operation was cancelled (typically by the caller).", + "details": NSNull() + ] + + eventCallback([ + "data": NSNull(), + "error": errorDict, + "done": true + ]) + return + } + + let nsError = error as NSError + + // Construct error object similar to httpsCallable + var details: Any = NSNull() + let message = error.localizedDescription + + if nsError.domain == "com.firebase.functions" { + if let errorDetails = nsError.userInfo["details"] { + details = errorDetails + } + } + + let errorDict: [String: Any] = [ + "code": getErrorCodeName(error), + "message": message, + "details": details + ] + + eventCallback([ + "data": NSNull(), + "error": errorDict, + "done": true + ]) + } + } +} + +// MARK: - Helper Types for Encoding/Decoding Any Value + +public struct AnyEncodable: Encodable { + private let value: Any? + + public init(_ value: Any?) { + self.value = value + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let value = value { + switch value { + case let string as String: + try container.encode(string) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let bool as Bool: + try container.encode(bool) + case let array as [Any]: + try container.encode(array.map { AnyEncodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyEncodable($0) }) + case is NSNull: + try container.encodeNil() + default: + try container.encodeNil() + } + } else { + try container.encodeNil() + } + } +} + +public struct AnyDecodable: Decodable { + public let value: Any? + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyDecodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyDecodable].self) { + value = dict.mapValues { $0.value } + } else { + value = NSNull() + } + } +} + diff --git a/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions-generated.mm b/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions-generated.mm index 7fff3b0c74..04c89d0132 100644 --- a/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions-generated.mm +++ b/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions-generated.mm @@ -47,6 +47,30 @@ + (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptio return facebook::react::managedPointer(json); } @end +@implementation RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamData) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamData:(id)json +{ + return facebook::react::managedPointer(json); +} +@end +@implementation RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamOptions) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamOptions:(id)json +{ + return facebook::react::managedPointer(json); +} +@end +@implementation RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlData) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlData:(id)json +{ + return facebook::react::managedPointer(json); +} +@end +@implementation RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlOptions) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlOptions:(id)json +{ + return facebook::react::managedPointer(json); +} +@end namespace facebook::react { static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallable(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { @@ -57,6 +81,18 @@ + (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptio return static_cast(turboModule).invokeObjCMethod(rt, PromiseKind, "httpsCallableFromUrl", @selector(httpsCallableFromUrl:region:emulatorHost:emulatorPort:url:data:options:resolve:reject:), args, count); } + static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStream(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, VoidKind, "httpsCallableStream", @selector(httpsCallableStream:region:emulatorHost:emulatorPort:name:data:options:listenerId:), args, count); + } + + static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStreamFromUrl(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, VoidKind, "httpsCallableStreamFromUrl", @selector(httpsCallableStreamFromUrl:region:emulatorHost:emulatorPort:url:data:options:listenerId:), args, count); + } + + static facebook::jsi::Value __hostFunction_NativeRNFBTurboFunctionsSpecJSI_removeFunctionsStreaming(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, VoidKind, "removeFunctionsStreaming", @selector(removeFunctionsStreaming:region:listenerId:), args, count); + } + NativeRNFBTurboFunctionsSpecJSI::NativeRNFBTurboFunctionsSpecJSI(const ObjCTurboModule::InitParams ¶ms) : ObjCTurboModule(params) { @@ -67,5 +103,16 @@ + (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptio methodMap_["httpsCallableFromUrl"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableFromUrl}; setMethodArgConversionSelector(@"httpsCallableFromUrl", 5, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlData:"); setMethodArgConversionSelector(@"httpsCallableFromUrl", 6, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptions:"); + + methodMap_["httpsCallableStream"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStream}; + setMethodArgConversionSelector(@"httpsCallableStream", 5, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamData:"); + setMethodArgConversionSelector(@"httpsCallableStream", 6, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamOptions:"); + + methodMap_["httpsCallableStreamFromUrl"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_httpsCallableStreamFromUrl}; + setMethodArgConversionSelector(@"httpsCallableStreamFromUrl", 5, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlData:"); + setMethodArgConversionSelector(@"httpsCallableStreamFromUrl", 6, @"JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlOptions:"); + + methodMap_["removeFunctionsStreaming"] = MethodMetadata {3, __hostFunction_NativeRNFBTurboFunctionsSpecJSI_removeFunctionsStreaming}; + } } // namespace facebook::react diff --git a/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions.h b/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions.h index f7e34a1777..030f194534 100644 --- a/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions.h +++ b/packages/functions/ios/generated/NativeRNFBTurboFunctions/NativeRNFBTurboFunctions.h @@ -92,6 +92,66 @@ namespace JS { @interface RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptions) + (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableFromUrlOptions:(id)json; @end +namespace JS { + namespace NativeRNFBTurboFunctions { + struct SpecHttpsCallableStreamData { + id data() const; + + SpecHttpsCallableStreamData(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamData) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamData:(id)json; +@end +namespace JS { + namespace NativeRNFBTurboFunctions { + struct SpecHttpsCallableStreamOptions { + std::optional timeout() const; + + SpecHttpsCallableStreamOptions(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamOptions) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamOptions:(id)json; +@end +namespace JS { + namespace NativeRNFBTurboFunctions { + struct SpecHttpsCallableStreamFromUrlData { + id data() const; + + SpecHttpsCallableStreamFromUrlData(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlData) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlData:(id)json; +@end +namespace JS { + namespace NativeRNFBTurboFunctions { + struct SpecHttpsCallableStreamFromUrlOptions { + std::optional timeout() const; + + SpecHttpsCallableStreamFromUrlOptions(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlOptions) ++ (RCTManagedPointer *)JS_NativeRNFBTurboFunctions_SpecHttpsCallableStreamFromUrlOptions:(id)json; +@end @protocol NativeRNFBTurboFunctionsSpec - (void)httpsCallable:(NSString *)appName @@ -112,6 +172,25 @@ namespace JS { options:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableFromUrlOptions &)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)httpsCallableStream:(NSString *)appName + region:(NSString *)region + emulatorHost:(NSString * _Nullable)emulatorHost + emulatorPort:(double)emulatorPort + name:(NSString *)name + data:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamData &)data + options:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamOptions &)options + listenerId:(double)listenerId; +- (void)httpsCallableStreamFromUrl:(NSString *)appName + region:(NSString *)region + emulatorHost:(NSString * _Nullable)emulatorHost + emulatorPort:(double)emulatorPort + url:(NSString *)url + data:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlData &)data + options:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlOptions &)options + listenerId:(double)listenerId; +- (void)removeFunctionsStreaming:(NSString *)appName + region:(NSString *)region + listenerId:(double)listenerId; @end @@ -153,5 +232,25 @@ inline std::optional JS::NativeRNFBTurboFunctions::SpecHttpsCallableFrom id const p = _v[@"timeout"]; return RCTBridgingToOptionalDouble(p); } +inline id JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamData::data() const +{ + id const p = _v[@"data"]; + return p; +} +inline std::optional JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamOptions::timeout() const +{ + id const p = _v[@"timeout"]; + return RCTBridgingToOptionalDouble(p); +} +inline id JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlData::data() const +{ + id const p = _v[@"data"]; + return p; +} +inline std::optional JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlOptions::timeout() const +{ + id const p = _v[@"timeout"]; + return RCTBridgingToOptionalDouble(p); +} NS_ASSUME_NONNULL_END #endif // NativeRNFBTurboFunctions_H diff --git a/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI-generated.cpp b/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI-generated.cpp index c892ffbf8b..31cddefdf8 100644 --- a/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI-generated.cpp +++ b/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI-generated.cpp @@ -35,11 +35,51 @@ static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallabl count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt) ); } +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStream(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->httpsCallableStream( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 || args[2].isNull() || args[2].isUndefined() ? std::nullopt : std::make_optional(args[2].asString(rt)), + count <= 3 ? throw jsi::JSError(rt, "Expected argument in position 3 to be passed") : args[3].asNumber(), + count <= 4 ? throw jsi::JSError(rt, "Expected argument in position 4 to be passed") : args[4].asString(rt), + count <= 5 ? throw jsi::JSError(rt, "Expected argument in position 5 to be passed") : args[5].asObject(rt), + count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt), + count <= 7 ? throw jsi::JSError(rt, "Expected argument in position 7 to be passed") : args[7].asNumber() + ); + return jsi::Value::undefined(); +} +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStreamFromUrl(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->httpsCallableStreamFromUrl( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 || args[2].isNull() || args[2].isUndefined() ? std::nullopt : std::make_optional(args[2].asString(rt)), + count <= 3 ? throw jsi::JSError(rt, "Expected argument in position 3 to be passed") : args[3].asNumber(), + count <= 4 ? throw jsi::JSError(rt, "Expected argument in position 4 to be passed") : args[4].asString(rt), + count <= 5 ? throw jsi::JSError(rt, "Expected argument in position 5 to be passed") : args[5].asObject(rt), + count <= 6 ? throw jsi::JSError(rt, "Expected argument in position 6 to be passed") : args[6].asObject(rt), + count <= 7 ? throw jsi::JSError(rt, "Expected argument in position 7 to be passed") : args[7].asNumber() + ); + return jsi::Value::undefined(); +} +static jsi::Value __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_removeFunctionsStreaming(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_cast(&turboModule)->removeFunctionsStreaming( + rt, + count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt), + count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt), + count <= 2 ? throw jsi::JSError(rt, "Expected argument in position 2 to be passed") : args[2].asNumber() + ); + return jsi::Value::undefined(); +} NativeRNFBTurboFunctionsCxxSpecJSI::NativeRNFBTurboFunctionsCxxSpecJSI(std::shared_ptr jsInvoker) : TurboModule("NativeRNFBTurboFunctions", jsInvoker) { methodMap_["httpsCallable"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallable}; methodMap_["httpsCallableFromUrl"] = MethodMetadata {7, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableFromUrl}; + methodMap_["httpsCallableStream"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStream}; + methodMap_["httpsCallableStreamFromUrl"] = MethodMetadata {8, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_httpsCallableStreamFromUrl}; + methodMap_["removeFunctionsStreaming"] = MethodMetadata {3, __hostFunction_NativeRNFBTurboFunctionsCxxSpecJSI_removeFunctionsStreaming}; } diff --git a/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI.h b/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI.h index 2cd49cbaee..4e9f4594a7 100644 --- a/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI.h +++ b/packages/functions/ios/generated/NativeRNFBTurboFunctionsJSI.h @@ -22,6 +22,9 @@ namespace facebook::react { public: virtual jsi::Value httpsCallable(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options) = 0; virtual jsi::Value httpsCallableFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options) = 0; + virtual void httpsCallableStream(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options, double listenerId) = 0; + virtual void httpsCallableStreamFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options, double listenerId) = 0; + virtual void removeFunctionsStreaming(jsi::Runtime &rt, jsi::String appName, jsi::String region, double listenerId) = 0; }; @@ -68,6 +71,30 @@ class JSI_EXPORT NativeRNFBTurboFunctionsCxxSpec : public TurboModule { return bridging::callFromJs( rt, &T::httpsCallableFromUrl, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(url), std::move(data), std::move(options)); } + void httpsCallableStream(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String name, jsi::Object data, jsi::Object options, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::httpsCallableStream) == 9, + "Expected httpsCallableStream(...) to have 9 parameters"); + + return bridging::callFromJs( + rt, &T::httpsCallableStream, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(name), std::move(data), std::move(options), std::move(listenerId)); + } + void httpsCallableStreamFromUrl(jsi::Runtime &rt, jsi::String appName, jsi::String region, std::optional emulatorHost, double emulatorPort, jsi::String url, jsi::Object data, jsi::Object options, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::httpsCallableStreamFromUrl) == 9, + "Expected httpsCallableStreamFromUrl(...) to have 9 parameters"); + + return bridging::callFromJs( + rt, &T::httpsCallableStreamFromUrl, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(emulatorHost), std::move(emulatorPort), std::move(url), std::move(data), std::move(options), std::move(listenerId)); + } + void removeFunctionsStreaming(jsi::Runtime &rt, jsi::String appName, jsi::String region, double listenerId) override { + static_assert( + bridging::getParameterCount(&T::removeFunctionsStreaming) == 4, + "Expected removeFunctionsStreaming(...) to have 4 parameters"); + + return bridging::callFromJs( + rt, &T::removeFunctionsStreaming, jsInvoker_, instance_, std::move(appName), std::move(region), std::move(listenerId)); + } private: friend class NativeRNFBTurboFunctionsCxxSpec; diff --git a/packages/functions/lib/HttpsError.ts b/packages/functions/lib/HttpsError.ts index 1a4650021f..c5837bb764 100644 --- a/packages/functions/lib/HttpsError.ts +++ b/packages/functions/lib/HttpsError.ts @@ -54,9 +54,9 @@ export class HttpsError extends Error { value: message, }); - this.stack = NativeFirebaseError.getStackWithMessage( - `Error: ${message}`, - nativeErrorInstance?.jsStack as string, - ); + // Build stack trace, falling back to basic Error stack if jsStack is not available + this.stack = nativeErrorInstance?.jsStack + ? NativeFirebaseError.getStackWithMessage(`Error: ${message}`, nativeErrorInstance.jsStack) + : new Error(message).stack || ''; } } diff --git a/packages/functions/lib/modular.ts b/packages/functions/lib/modular.ts index 873a3975c6..c5190c5699 100644 --- a/packages/functions/lib/modular.ts +++ b/packages/functions/lib/modular.ts @@ -15,14 +15,9 @@ * */ -import { getApp } from '@react-native-firebase/app'; +import { getApp, type FirebaseApp } from '@react-native-firebase/app'; import { MODULAR_DEPRECATION_ARG } from '@react-native-firebase/app/lib/common'; -import type { - Functions, - FirebaseApp, - HttpsCallableOptions, - HttpsCallable, -} from './types/functions'; +import type { Functions, HttpsCallableOptions, HttpsCallable } from './types/functions'; /** * Returns a Functions instance for the given app. @@ -61,18 +56,18 @@ export function connectFunctionsEmulator( * @param options An interface for metadata about how calls should be executed. * @returns HttpsCallable instance */ -export function httpsCallable( +export function httpsCallable( functionsInstance: Functions, name: string, options?: HttpsCallableOptions, -): HttpsCallable { +): HttpsCallable { return functionsInstance.httpsCallable.call( functionsInstance, name, options, // @ts-ignore MODULAR_DEPRECATION_ARG, - ) as HttpsCallable; + ) as HttpsCallable; } /** @@ -82,16 +77,20 @@ export function httpsCallable( * @param options An instance of HttpsCallableOptions containing metadata about how calls should be executed. * @returns HttpsCallable instance */ -export function httpsCallableFromUrl( +export function httpsCallableFromUrl< + RequestData = unknown, + ResponseData = unknown, + StreamData = unknown, +>( functionsInstance: Functions, url: string, options?: HttpsCallableOptions, -): HttpsCallable { +): HttpsCallable { return functionsInstance.httpsCallableFromUrl.call( functionsInstance, url, options, // @ts-ignore MODULAR_DEPRECATION_ARG, - ) as HttpsCallable; + ) as HttpsCallable; } diff --git a/packages/functions/lib/namespaced.ts b/packages/functions/lib/namespaced.ts index a580c2b1c1..b3c96cd865 100644 --- a/packages/functions/lib/namespaced.ts +++ b/packages/functions/lib/namespaced.ts @@ -16,17 +16,24 @@ */ import { isAndroid, isNumber } from '@react-native-firebase/app/lib/common'; +import type { ModuleConfig } from '@react-native-firebase/app/lib/internal'; import { createModuleNamespace, FirebaseModule, getFirebaseRoot, } from '@react-native-firebase/app/lib/internal'; -import type { ModuleConfig } from '@react-native-firebase/app/lib/internal'; import { HttpsError, type NativeError } from './HttpsError'; import { version } from './version'; import { setReactNativeModule } from '@react-native-firebase/app/lib/internal/nativeModule'; import fallBackModule from './web/RNFBFunctionsModule'; -import type { HttpsCallableOptions, Functions, FunctionsStatics } from './types/functions'; +import type { + HttpsCallableOptions, + HttpsCallableStreamOptions, + Functions, + FunctionsStatics, + HttpsCallable, +} from './types/functions'; +import type { FunctionsStreamingEvent } from './types/internal'; import type { ReactNativeFirebase } from '@react-native-firebase/app'; const namespace = 'functions'; @@ -80,6 +87,7 @@ class FirebaseFunctionsModule extends FirebaseModule { _customUrlOrRegion: string; private _useFunctionsEmulatorHost: string | null; private _useFunctionsEmulatorPort: number; + private _id_functions_streaming_event: number; constructor( app: ReactNativeFirebase.FirebaseAppBase, @@ -90,6 +98,153 @@ class FirebaseFunctionsModule extends FirebaseModule { this._customUrlOrRegion = customUrlOrRegion || 'us-central1'; this._useFunctionsEmulatorHost = null; this._useFunctionsEmulatorPort = -1; + this._id_functions_streaming_event = 0; + + // @ts-ignore - emitter and eventNameForApp exist on FirebaseModule + this.emitter.addListener( + // @ts-ignore + this.eventNameForApp('functions_streaming_event'), + (event: FunctionsStreamingEvent) => { + // @ts-ignore + this.emitter.emit( + // @ts-ignore + this.eventNameForApp(`functions_streaming_event:${event.listenerId}`), + event, + ); + }, + ); + } + + /** + * Private helper method to create a streaming handler for callable functions. + * This method encapsulates the common streaming logic used by both + * httpsCallable and httpsCallableFromUrl. + */ + private async _createStreamHandler( + initiateStream: (listenerId: number) => void, + ): Promise<{ stream: AsyncGenerator; data: Promise }> { + const listenerId = this._id_functions_streaming_event++; + const eventName = this.eventNameForApp(`functions_streaming_event:${listenerId}`); + const nativeModule = this.native; + + // Capture JavaScript stack at stream creation time so an error can be thrown with the correct stack trace + const capturedStack = new Error().stack; + + // Queue to buffer events before iteration starts + const eventQueue: unknown[] = []; + let resolveNext: ((value: IteratorResult) => void) | null = null; + let error: Error | null = null; + let done = false; + let finalData: unknown = null; + + const subscription = this.emitter.addListener(eventName, (event: FunctionsStreamingEvent) => { + const body = event.body; + + if (body.error) { + const { code, message, details } = body.error || {}; + error = new HttpsError( + HttpsErrorCode[code as keyof typeof HttpsErrorCode] || HttpsErrorCode.UNKNOWN, + message || 'Unknown error', + details ?? null, + { jsStack: capturedStack }, + ); + done = true; + subscription.remove(); + if (nativeModule.removeFunctionsStreaming) { + nativeModule.removeFunctionsStreaming(listenerId); + } + if (resolveNext) { + resolveNext({ done: true, value: undefined }); + resolveNext = null; + } + return; + } + + if (body.done) { + finalData = body.data; + done = true; + subscription.remove(); + if (nativeModule.removeFunctionsStreaming) { + nativeModule.removeFunctionsStreaming(listenerId); + } + if (resolveNext) { + resolveNext({ done: true, value: undefined }); + resolveNext = null; + } + } else if (body.data !== null && body.data !== undefined) { + // This is a chunk + if (resolveNext) { + resolveNext({ done: false, value: body.data }); + resolveNext = null; + } else { + eventQueue.push(body.data); + } + } + }); + + // Start native streaming via the provided callback + initiateStream(listenerId); + + // Use async generator function for better compatibility with Hermes/React Native + async function* streamGenerator() { + try { + while (true) { + if (error) { + throw error; + } + + if (eventQueue.length > 0) { + yield eventQueue.shift(); + continue; + } + + if (done) { + return; + } + + // Wait for next event + const result = await new Promise>(resolve => { + resolveNext = resolve; + }); + + // Check result after promise resolves + if (result.done || done) { + return; + } + + if (result.value !== undefined && result.value !== null) { + yield result.value; + } + } + } finally { + // Cleanup when generator is closed/returned + subscription.remove(); + if (nativeModule.removeFunctionsStreaming) { + nativeModule.removeFunctionsStreaming(listenerId); + } + } + } + + const asyncIterator = streamGenerator(); + + // Create a promise that resolves with the final data + const dataPromise = new Promise((resolve, reject) => { + const checkComplete = () => { + if (error) { + reject(error); + } else if (done) { + resolve(finalData); + } else { + setTimeout(checkComplete, 10); + } + }; + checkComplete(); + }); + + return { + stream: asyncIterator, + data: dataPromise, + }; } httpsCallable(name: string, options: HttpsCallableOptions = {}) { @@ -101,7 +256,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction = ((data?: unknown) => { const nativePromise = this.native.httpsCallable( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -122,7 +277,27 @@ class FirebaseFunctionsModule extends FirebaseModule { ), ); }); + }) as HttpsCallable; + + // Add async iterable streaming helper + // Usage: const { stream, data } = await functions().httpsCallable('fn').stream(data, options) + callableFunction.stream = async ( + data?: unknown, + streamOptions?: HttpsCallableStreamOptions, + ) => { + return this._createStreamHandler(listenerId => { + this.native.httpsCallableStream( + this._useFunctionsEmulatorHost || null, + this._useFunctionsEmulatorPort || -1, + name, + { data }, + streamOptions || {}, + listenerId, + ); + }); }; + + return callableFunction; } httpsCallableFromUrl(url: string, options: HttpsCallableOptions = {}) { @@ -134,7 +309,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction = ((data?: unknown) => { const nativePromise = this.native.httpsCallableFromUrl( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -155,7 +330,24 @@ class FirebaseFunctionsModule extends FirebaseModule { ), ); }); + }) as HttpsCallable; + + callableFunction.stream = async ( + data?: unknown, + streamOptions?: HttpsCallableStreamOptions, + ) => { + return this._createStreamHandler(listenerId => { + this.native.httpsCallableStreamFromUrl( + this._useFunctionsEmulatorHost || null, + this._useFunctionsEmulatorPort || -1, + url, + { data }, + streamOptions || {}, + listenerId, + ); + }); }; + return callableFunction; } useFunctionsEmulator(origin: string): void { @@ -207,7 +399,7 @@ const functionsNamespace = createModuleNamespace({ version, namespace, nativeModuleName, - nativeEvents: false, + nativeEvents: ['functions_streaming_event'], hasMultiAppSupport: true, hasCustomUrlOrRegionSupport: true, ModuleClass: FirebaseFunctionsModule, diff --git a/packages/functions/lib/types/functions.ts b/packages/functions/lib/types/functions.ts index 852a79a0f5..7111ceacae 100644 --- a/packages/functions/lib/types/functions.ts +++ b/packages/functions/lib/types/functions.ts @@ -21,14 +21,47 @@ import type { ReactNativeFirebase } from '@react-native-firebase/app'; export interface HttpsCallableOptions { timeout?: number; + /** + * If set to true, uses a limited-use App Check token for callable function requests from this + * instance of {@link Functions}. You must use limited-use tokens to call functions with + * replay protection enabled. By default, this is false. + */ + limitedUseAppCheckTokens?: boolean; +} + +export interface HttpsCallableStreamOptions { + /** + * Web only. An `AbortSignal` that can be used to cancel the streaming response. When the signal is aborted, + * the underlying HTTP connection will be terminated. + */ + signal?: AbortSignal; + /** + * If set to true, uses a limited-use App Check token for callable function requests from this + * instance of {@link Functions}. You must use limited-use tokens to call functions with + * replay protection enabled. By default, this is false. + */ + limitedUseAppCheckTokens?: boolean; } export interface HttpsCallableResult { readonly data: ResponseData; } -export interface HttpsCallable { +export interface HttpsCallableStreamResult { + readonly data: Promise; + readonly stream: AsyncIterable; +} + +export interface HttpsCallable< + RequestData = unknown, + ResponseData = unknown, + StreamData = unknown, +> { (data?: RequestData | null): Promise>; + stream: ( + data?: RequestData | null, + options?: HttpsCallableStreamOptions, + ) => Promise>; } // ============ Error Code Types ============ @@ -96,10 +129,10 @@ export interface Functions extends ReactNativeFirebase.FirebaseModule { * @param name The name of the trigger. * @param options Optional settings for the callable function. */ - httpsCallable( + httpsCallable( name: string, options?: HttpsCallableOptions, - ): HttpsCallable; + ): HttpsCallable; /** * Returns a reference to the callable HTTPS trigger with the given URL. @@ -107,10 +140,31 @@ export interface Functions extends ReactNativeFirebase.FirebaseModule { * @param url The URL of the trigger. * @param options Optional settings for the callable function. */ - httpsCallableFromUrl( + httpsCallableFromUrl( + url: string, + options?: HttpsCallableOptions, + ): HttpsCallable; + + /** + * Returns a reference to the callable HTTPS trigger with the given name. + * + * @param name The name of the trigger. + * @param options Optional settings for the callable function. + */ + httpsCallableStream( + name: string, + options?: HttpsCallableOptions, + ): HttpsCallable; + /** + * Returns a reference to the callable HTTPS trigger with the given URL. + * + * @param url The URL of the trigger. + * @param options Optional settings for the callable function. + */ + httpsCallableStreamFromUrl( url: string, options?: HttpsCallableOptions, - ): HttpsCallable; + ): HttpsCallable; /** * Changes this instance to point to a Cloud Functions emulator running locally. @@ -165,8 +219,17 @@ declare module '@react-native-firebase/app' { // Helper types to reference outer scope types within the namespace // These are needed because TypeScript can't directly alias types with the same name type _HttpsCallableResult = HttpsCallableResult; -type _HttpsCallable = HttpsCallable; +type _HttpsCallableStreamResult = HttpsCallableStreamResult< + ResponseData, + StreamData +>; +type _HttpsCallable = HttpsCallable< + RequestData, + ResponseData, + StreamData +>; type _HttpsCallableOptions = HttpsCallableOptions; +type _HttpsCallableStreamOptions = HttpsCallableStreamOptions; type _HttpsError = HttpsError; type _HttpsErrorCode = HttpsErrorCode; @@ -179,11 +242,17 @@ export namespace FirebaseFunctionsTypes { // Short name aliases referencing top-level types export type ErrorCode = FunctionsErrorCode; export type CallableResult = HttpsCallableResult; - export type Callable = HttpsCallable< - RequestData, - ResponseData - >; + export type CallableStreamResult< + ResponseData = unknown, + StreamData = unknown, + > = HttpsCallableStreamResult; + export type Callable< + RequestData = unknown, + ResponseData = unknown, + StreamData = unknown, + > = HttpsCallable; export type CallableOptions = HttpsCallableOptions; + export type CallableStreamOptions = HttpsCallableStreamOptions; export type Error = HttpsError; export type ErrorCodeMap = HttpsErrorCode; export type Statics = FunctionsStatics; @@ -192,11 +261,17 @@ export namespace FirebaseFunctionsTypes { // Https* aliases that reference the exported types above via helper types // These provide backwards compatibility for code using FirebaseFunctionsTypes.HttpsCallableResult export type HttpsCallableResult = _HttpsCallableResult; - export type HttpsCallable = _HttpsCallable< - RequestData, - ResponseData - >; + export type HttpsCallableStreamResult< + ResponseData = unknown, + StreamData = unknown, + > = _HttpsCallableStreamResult; + export type HttpsCallable< + RequestData = unknown, + ResponseData = unknown, + StreamData = unknown, + > = _HttpsCallable; export type HttpsCallableOptions = _HttpsCallableOptions; + export type HttpsCallableStreamOptions = _HttpsCallableStreamOptions; export type HttpsError = _HttpsError; export type HttpsErrorCode = _HttpsErrorCode; } diff --git a/packages/functions/lib/types/internal.ts b/packages/functions/lib/types/internal.ts new file mode 100644 index 0000000000..1f66301cdf --- /dev/null +++ b/packages/functions/lib/types/internal.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export interface FunctionsStreamingEventBody { + data?: unknown; + done?: boolean; + error?: { + code: string; + message: string; + details?: Record; + }; +} + +export interface FunctionsStreamingEvent { + eventName: string; + listenerId: number; + body: FunctionsStreamingEventBody; +} diff --git a/packages/functions/lib/web/RNFBFunctionsModule.ts b/packages/functions/lib/web/RNFBFunctionsModule.ts index 989ca52120..a552b23657 100644 --- a/packages/functions/lib/web/RNFBFunctionsModule.ts +++ b/packages/functions/lib/web/RNFBFunctionsModule.ts @@ -5,6 +5,7 @@ import { httpsCallableFromURL, connectFunctionsEmulator, } from '@react-native-firebase/app/lib/internal/web/firebaseFunctions'; +import { emitEvent } from '@react-native-firebase/app/lib/internal/web/utils'; import type { HttpsCallableOptions } from '../index'; import type { NativeError } from '../HttpsError'; @@ -12,6 +13,15 @@ interface WrapperData { data?: any; } +// Map to store active streaming subscriptions by listenerId +// Subscription type from RxJS has an unsubscribe() method +interface Subscription { + unsubscribe(): void; +} + +const functionsStreamingListeners = new Map(); +// null value in map indicates listener was cancelled before subscription was created + /** * This is a 'NativeModule' for the web platform. * Methods here are identical to the ones found in @@ -143,4 +153,391 @@ export default { return Promise.reject(nativeError); } }, + + /** + * Stream a Firebase Functions callable. + * @param appName - The name of the app to get the functions instance for. + * @param regionOrCustomDomain - The region or custom domain to use for the functions instance. + * @param host - The host to use for the functions emulator. + * @param port - The port to use for the functions emulator. + * @param name - The name of the functions callable. + * @param wrapper - The wrapper object to use for the functions callable. + * @param options - The options to use for the functions callable. + * @param listenerId - The listener ID for this stream. + */ + httpsCallableStream( + appName: string, + regionOrCustomDomain: string | null, + host: string | null, + port: number, + name: string, + wrapper: WrapperData, + options: HttpsCallableOptions, + listenerId: number, + ): void { + // Wrap entire function to catch any synchronous errors + try { + const app = getApp(appName); + let functionsInstance; + if (regionOrCustomDomain) { + functionsInstance = getFunctions(app, regionOrCustomDomain); + // Hack to work around custom domain and region not being set on the instance. + if (regionOrCustomDomain.startsWith('http')) { + functionsInstance.customDomain = regionOrCustomDomain; + functionsInstance.region = 'us-central1'; + } else { + functionsInstance.region = regionOrCustomDomain; + functionsInstance.customDomain = null; + } + } else { + functionsInstance = getFunctions(app); + functionsInstance.region = 'us-central1'; + functionsInstance.customDomain = null; + } + if (host) { + connectFunctionsEmulator(functionsInstance, host, port); + // Hack to work around emulator origin not being set on the instance. + functionsInstance.emulatorOrigin = `http://${host}:${port}`; + } + + let callable; + if (Object.keys(options).length) { + callable = httpsCallable(functionsInstance, name, options); + } else { + callable = httpsCallable(functionsInstance, name); + } + + // if data is undefined use null + const data = wrapper['data'] ?? null; + + // Defer streaming to next tick to ensure event listeners are set up + setTimeout(() => { + try { + // Check if listener was cancelled before subscription creation (null marker) + // or if subscription already exists + const existing = functionsStreamingListeners.get(listenerId); + if (existing === null) { + // Was cancelled, remove the marker and don't create subscription + functionsStreamingListeners.delete(listenerId); + return; + } + if (existing !== undefined) { + // Subscription already exists + return; + } + + // Call the streaming version + const callableWithStream = callable as any; + + if (typeof callableWithStream.stream === 'function') { + const subscription = callableWithStream.stream(data).subscribe({ + next: (chunk: any) => { + // Check if still active before emitting + if (functionsStreamingListeners.has(listenerId)) { + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: chunk.data ?? null, + error: null, + done: false, + }, + }); + } + }, + error: (error: any) => { + const { code, message, details } = error; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + // Remove listener on error + functionsStreamingListeners.delete(listenerId); + }, + complete: () => { + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: null, + done: true, + }, + }); + // Remove listener on completion + functionsStreamingListeners.delete(listenerId); + }, + }); + + // Check if was cancelled during subscription creation (before storing) + // If removeFunctionsStreaming was called, it would have set it to null + const checkBeforeStorage = functionsStreamingListeners.get(listenerId); + if (checkBeforeStorage === null) { + // Was cancelled during subscription creation, unsubscribe immediately + subscription.unsubscribe(); + functionsStreamingListeners.delete(listenerId); + return; + } + + // Store subscription in the listeners map + functionsStreamingListeners.set(listenerId, subscription); + } else { + // Fallback: streaming not supported, emit error + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: 'unsupported', + message: 'Streaming is not supported in this Firebase SDK version', + details: null, + }, + done: true, + }, + }); + } + } catch (streamError: any) { + // Error during streaming setup + const { code, message, details } = streamError; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || streamError.toString(), + details, + }, + done: true, + }, + }); + } + }, 0); // Execute on next tick + } catch (error: any) { + // Synchronous error during setup - emit immediately + const { code, message, details } = error; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + } + }, + + /** + * Stream a Firebase Functions callable from a URL. + * @param appName - The name of the app to get the functions instance for. + * @param regionOrCustomDomain - The region or custom domain to use for the functions instance. + * @param host - The host to use for the functions emulator. + * @param port - The port to use for the functions emulator. + * @param url - The URL to use for the functions callable. + * @param wrapper - The wrapper object to use for the functions callable. + * @param options - The options to use for the functions callable. + * @param listenerId - The listener ID for this stream. + */ + httpsCallableStreamFromUrl( + appName: string, + regionOrCustomDomain: string | null, + host: string | null, + port: number, + url: string, + wrapper: WrapperData, + options: HttpsCallableOptions, + listenerId: number, + ): void { + try { + const app = getApp(appName); + let functionsInstance; + if (regionOrCustomDomain) { + functionsInstance = getFunctions(app, regionOrCustomDomain); + // Hack to work around custom domain and region not being set on the instance. + if (regionOrCustomDomain.startsWith('http')) { + functionsInstance.customDomain = regionOrCustomDomain; + } else { + functionsInstance.region = regionOrCustomDomain; + } + } else { + functionsInstance = getFunctions(app); + functionsInstance.region = 'us-central1'; + functionsInstance.customDomain = null; + } + if (host) { + connectFunctionsEmulator(functionsInstance, host, port); + // Hack to work around emulator origin not being set on the instance. + functionsInstance.emulatorOrigin = `http://${host}:${port}`; + } + + const callable = httpsCallableFromURL(functionsInstance, url, options); + const data = wrapper['data'] ?? null; + + // Defer streaming to next tick to ensure event listeners are set up + setTimeout(() => { + try { + // Check if listener was cancelled before subscription creation (null marker) + // or if subscription already exists + const existing = functionsStreamingListeners.get(listenerId); + if (existing === null) { + // Was cancelled, remove the marker and don't create subscription + functionsStreamingListeners.delete(listenerId); + return; + } + if (existing !== undefined) { + // Subscription already exists + return; + } + + // Call the streaming version + const callableWithStream = callable as any; + + if (typeof callableWithStream.stream === 'function') { + const subscription = callableWithStream.stream(data).subscribe({ + next: (chunk: any) => { + // Check if still active before emitting + if (functionsStreamingListeners.has(listenerId)) { + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: chunk.data ?? null, + error: null, + done: false, + }, + }); + } + }, + error: (error: any) => { + const { code, message, details } = error; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + // Remove listener on error + functionsStreamingListeners.delete(listenerId); + }, + complete: () => { + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: null, + done: true, + }, + }); + // Remove listener on completion + functionsStreamingListeners.delete(listenerId); + }, + }); + + // Check if was cancelled during subscription creation (before storing) + // If removeFunctionsStreaming was called, it would have set it to null + const checkBeforeStorage = functionsStreamingListeners.get(listenerId); + if (checkBeforeStorage === null) { + // Was cancelled during subscription creation, unsubscribe immediately + subscription.unsubscribe(); + functionsStreamingListeners.delete(listenerId); + return; + } + + // Store subscription in the listeners map + functionsStreamingListeners.set(listenerId, subscription); + } else { + // Fallback: streaming not supported, emit error + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: 'unsupported', + message: 'Streaming is not supported in this Firebase SDK version', + details: null, + }, + done: true, + }, + }); + } + } catch (streamError: any) { + // Error during streaming setup + const { code, message, details } = streamError; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || streamError.toString(), + details, + }, + done: true, + }, + }); + } + }, 0); // Execute on next tick + } catch (error: any) { + // Synchronous error during setup - emit immediately + const { code, message, details } = error; + emitEvent('functions_streaming_event', { + eventName: 'functions_streaming_event', + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + } + }, + + /** + * Removes a streaming listener. + * @param listenerId - The listener ID to remove. + */ + removeFunctionsStreaming(listenerId: number): void { + const subscription = functionsStreamingListeners.get(listenerId); + if (subscription) { + // Unsubscribe from the RxJS stream + subscription.unsubscribe(); + // Remove from the listeners map + functionsStreamingListeners.delete(listenerId); + } else if (subscription === undefined) { + // Subscription hasn't been created yet (setTimeout hasn't executed) + // Mark as cancelled with null marker so it won't be created + functionsStreamingListeners.set(listenerId, null); + } + // If subscription === null, it was already cancelled, do nothing + }, }; diff --git a/packages/functions/lib/web/types.d.ts b/packages/functions/lib/web/types.d.ts new file mode 100644 index 0000000000..31bbf8b00b --- /dev/null +++ b/packages/functions/lib/web/types.d.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/** + * Augment the Firebase Functions SDK to include additional properties + * which are set as workarounds for emulator configuration and region/custom domain on web. + */ +declare module 'firebase/functions' { + interface Functions { + emulatorOrigin?: string; + region?: string; + customDomain?: string | null; + } +} + +/** + * Type declarations for firebaseFunctions module exports + * which re-exports from firebase/functions. + */ +declare module '@react-native-firebase/app/lib/internal/web/firebaseFunctions' { + import type { FirebaseApp } from 'firebase/app'; + import type { Functions, HttpsCallable, HttpsCallableOptions } from 'firebase/functions'; + + export function getFunctions(app?: FirebaseApp, regionOrCustomDomain?: string): Functions; + export function httpsCallable( + functionsInstance: Functions, + name: string, + options?: HttpsCallableOptions, + ): HttpsCallable; + export function httpsCallableFromURL( + functionsInstance: Functions, + url: string, + options?: HttpsCallableOptions, + ): HttpsCallable; + export function connectFunctionsEmulator( + functionsInstance: Functions, + host: string, + port: number, + ): void; + // Re-export everything from firebase/app and firebase/functions + export * from 'firebase/app'; + export * from 'firebase/functions'; +} + +/** + * Type declaration for RNFBAppModule to include eventsSendEvent method + * used for streaming events on web platform. + */ +declare module '@react-native-firebase/app/lib/internal/web/RNFBAppModule' { + interface RNFBAppModuleType { + NATIVE_FIREBASE_APPS: any[]; + FIREBASE_RAW_JSON: string; + initializeApp(options: any, appConfig: any): Promise; + deleteApp(name: string): Promise; + setLogLevel(logLevel: string): void; + metaGetAll(): Promise<{ [key: string]: string | boolean }>; + jsonGetAll(): Promise<{ [key: string]: string | boolean }>; + preferencesClearAll(): Promise; + preferencesGetAll(): Promise<{ [key: string]: string | boolean }>; + preferencesSetBool(key: string, value: boolean): Promise; + preferencesSetString(key: string, value: string): void; + setAutomaticDataCollectionEnabled(name: string, enabled: boolean): void; + eventsNotifyReady(ready: boolean): void; + eventsAddListener(eventType: string): void; + eventsRemoveListener(eventType: string, removeAll?: boolean): void; + addListener?: (eventName: string) => void; + removeListeners?: (count: number) => void; + eventsSendEvent(eventName: string, eventBody: any): void; + } + const RNFBAppModule: RNFBAppModuleType; + export default RNFBAppModule; +} diff --git a/packages/functions/specs/NativeRNFBTurboFunctions.ts b/packages/functions/specs/NativeRNFBTurboFunctions.ts index 63d60ba666..3d2a74849b 100644 --- a/packages/functions/specs/NativeRNFBTurboFunctions.ts +++ b/packages/functions/specs/NativeRNFBTurboFunctions.ts @@ -45,6 +45,57 @@ export interface Spec extends TurboModule { data: { data: RequestData }, options: { timeout?: number }, ): Promise<{ data: ResponseData }>; + + /** + * Calls a Cloud Function with streaming support, emitting events as they arrive. + * + * @param emulatorHost - The emulator host (can be null) + * @param emulatorPort - The emulator port (can be -1 for no emulator) + * @param name - The name of the Cloud Function to call + * @param data - The data to pass to the function + * @param options - Additional options for the call + * @param listenerId - Unique identifier for this stream listener + */ + httpsCallableStream( + appName: string, + region: string, + emulatorHost: string | null, + emulatorPort: number, + name: string, + data: { data: RequestData }, + options: { timeout?: number }, + listenerId: number, + ): void; + + /** + * Calls a Cloud Function using a full URL with streaming support. + * + * @param emulatorHost - The emulator host (can be null) + * @param emulatorPort - The emulator port (can be -1 for no emulator) + * @param url - The full URL of the Cloud Function + * @param data - The data to pass to the function + * @param options - Additional options for the call + * @param listenerId - Unique identifier for this stream listener + */ + httpsCallableStreamFromUrl( + appName: string, + region: string, + emulatorHost: string | null, + emulatorPort: number, + url: string, + data: { data: RequestData }, + options: { timeout?: number }, + listenerId: number, + ): void; + + /** + * Removes/cancels a streaming listener. + * + * @param appName - The app name + * @param region - The region + * @param listenerId - The listener ID to remove + */ + removeFunctionsStreaming(appName: string, region: string, listenerId: number): void; } export default TurboModuleRegistry.getEnforcing('NativeRNFBTurboFunctions'); diff --git a/tests/ios/Podfile.lock b/tests/ios/Podfile.lock index cb97fc36c4..21eb31b1ed 100644 --- a/tests/ios/Podfile.lock +++ b/tests/ios/Podfile.lock @@ -2539,85 +2539,85 @@ SPEC CHECKSUMS: nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 + RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 RCTDeprecation: cf39863b43871c2031050605fb884019b6193910 RCTRequired: 8fdd66f4a97f352b66f38cfef13fc11b12d2c884 RCTTypeSafety: c9c9e64389bc545fc137030615b387ef1654dcee React: 14a80ea4f13387cfdaa4250b46fbfe19754c220c React-callinvoker: fed1dad5d6cf992c7b4b5fdbf1bf67fe2e8fb6c5 - React-Core: 3c803e7f3be6fa68e3dabcac283a5a5f87340a60 - React-CoreModules: 94d556b4055defb79278c3afba95e521998b9b3a - React-cxxreact: 21c826a567199cc2d5797bd4cfe3c0d94bb3a7de + React-Core: f703e7a56fcedc3e959b8b7899995e57fd58539a + React-CoreModules: 6e87c904cc257058c271708eef1719b5b3039131 + React-cxxreact: 4153beeff710944832cd90ccb141e299ee16b7d3 React-debug: a665bbe67eb786b7a7a815ce1b7212b3f9fa962c - React-defaultsnativemodule: 8f0bea8d0d3434aa98957302f2a8741600781edd - React-domnativemodule: 889d261cc9691b91063a1c1d89d408e19903923d - React-Fabric: eb0bde19a858807eee7d4fee07f24f036bdf5e6a - React-FabricComponents: 7756ddcd87ff121d0dedff69c80fd6177ccd31d9 - React-FabricImage: c3096fe8c20a4aec77c54c271f878948efa9d477 + React-defaultsnativemodule: 7e4a2c2b13ec2943f2f3b8adec32130443909de6 + React-domnativemodule: dffaa482180243bd1e2b7fba329fd4adc12a2445 + React-Fabric: bd742f0ddb6073ff14f7e51075bc0380b84e7f7a + React-FabricComponents: 347a74f275f989b493ab5324a2161e6b3de5556e + React-FabricImage: f9da31053da5aae09b7f67afdc46329c6e3a2710 React-featureflags: 55800b546a28b63a8a0f419e1a45871d43523d32 - React-featureflagsnativemodule: b200bca78e00f9e5c7cd5a1c9f2957223fcfa33a - React-graphics: fb41a6a55ecd473159452c75b3ea7b57794903a3 - React-hermes: 3be7f73615e70b310b0457146691d39ea3752e6b - React-idlecallbacksnativemodule: dc10ee2e5ba5ae7ad56aa093aedda582345bce16 - React-ImageManager: 08592583c7737aec2b2c2a12e7c4f0ad763ae5c4 - React-jserrorhandler: eede06f57f67c8d3978ff1a9c78107aea5cbdf45 - React-jsi: 70ca5cce94e7f81e4ff51a71b4720b5fb943eea5 - React-jsiexecutor: 265d9fbb2a88c74892646e0012cec62ebb89edcf - React-jsinspector: 7a7e033c64cc40e6a1d6daf7fad4827bc1e7dd12 - React-jsinspectortracing: 77f4d18502af6e7599e77390b35f04f02814f7ce - React-jsitracing: 0608ea7ee711370d42fdd2b4a474dbced68dd275 - React-logger: 8d00d3d794041a77bd890158353361e2709b04c1 - React-Mapbuffer: 45ca4d30efe99834a8cd8d98f803c94766db534f - React-microtasksnativemodule: b5901a0b15f92ce0666ee5131eb8ab646f1d5a27 - React-NativeModulesApple: 7a9ec626a1852d444d0e699b016dc55a064b7569 - React-perflogger: d06f0fd0727356651a5535f6d717130187aeb667 - React-performancetimeline: c397114f2c025aa73412a9f21e021b08127fe820 + React-featureflagsnativemodule: a0ea334fdd3342a2e4dc05085c3e7653e16839d3 + React-graphics: 7360f5f3611fd5982aa0de772a73987ab401fb02 + React-hermes: a942bebef5e9fcc31f51c6fb814e96c260a2a20d + React-idlecallbacksnativemodule: 74d091304aad1ceb0c5b86e5dec14372fcdc0f34 + React-ImageManager: 6b2a95469d9a126f14410bbe10fb7378d37ed0e0 + React-jserrorhandler: 2643140639cbf047bf45f5c1a3ea95b04d748582 + React-jsi: b2de88284fc2cc69466a34d8b794160216d3bd2c + React-jsiexecutor: e947af1c9e42610affd9f4178cd4b649e8ac889b + React-jsinspector: 6d768dfb189027f7ff2161be31ccd69215426ded + React-jsinspectortracing: a6a70eb5c9d767d99391d0373330a7239fb6f9d0 + React-jsitracing: 69280997c7a80ac0af62b95f01a241d68581fb52 + React-logger: e6c3c1b55c18cc1b945b647ff9ada53e0e710b50 + React-Mapbuffer: 57bea44149376ecf1241dd9f02a6222adab43e57 + React-microtasksnativemodule: 2739fc8a8cb99486820c933ce560791c93be5337 + React-NativeModulesApple: 4849912ee050e6ae346f5789bd631570580d8b84 + React-perflogger: 069d41f741187be92ed46c6ac67add03b99f3166 + React-performancetimeline: eda794c5007eb6834e13bc6026a879b5f6d49c74 React-RCTActionSheet: a078d5008632fed31b0024c420ee02e612b317d5 - React-RCTAnimation: b197cc109a896c6ce23981e02e317cfc055f6fda - React-RCTAppDelegate: f7f1d7362256b7c142d9ab49f980df488101f869 - React-RCTBlob: c12d15d40db01ac3fe57c24d3ef5233ff3333da6 - React-RCTFabric: 8cdcde7157a22aac04dfeb579dfc3a1141446846 - React-RCTFBReactNativeSpec: c3a78cb9f2a98146443f1b732a4f21b2ce736abd - React-RCTImage: 7a3d9d67161c714fa4d9b93820da39a266d0f1ff - React-RCTLinking: f860b917500cd3974235a48d5b199a74a4ed6c26 - React-RCTNetwork: 6a984ab1b5a81d17a2df6cc02c24b249fb055deb - React-RCTSettings: e9a39068d8b60d78a5271dcb68f6ea7f59569cb2 - React-RCTText: 44457242238664a5ad69f06ec7a5f273a6967711 - React-RCTVibration: f448ad875c60b2ddc5fc5b06d3f5e2dfc3f18c09 + React-RCTAnimation: 82e31d191af4175e0c2df5bdac2c8569a5f3ab54 + React-RCTAppDelegate: a5c1ff79f5987462b4f62b27387459ba84012439 + React-RCTBlob: c462b8b7de6ce44ddc56dd96eebe1da0a6e54c77 + React-RCTFabric: 56b946204edb5d563885b3b045bdacbb387b27e7 + React-RCTFBReactNativeSpec: 8392ef66ad156cfa848546859bbff3b5e8a09458 + React-RCTImage: 10fad63f1bb8adbd519c4c2ef6bec3c0d95fdd32 + React-RCTLinking: 3843288a44dc33ec083c843f3ff31dd7d96ece41 + React-RCTNetwork: f237299bda8bbd56c4d01d2825110e40b75c438a + React-RCTSettings: c24ce1ee96c9b001ff5059ddd53412a20b7d5e71 + React-RCTText: d97cfb9c89b06de9530577dd43f178c47ea07853 + React-RCTVibration: 2fcefee071a4f0d416e4368416bb073ea6893451 React-rendererconsistency: c9f31b6d55877c5d49d25d69270b89f9cb208e26 - React-rendererdebug: 939c31f97f3bbf434832b7f73d8a438cf96ee1c4 + React-rendererdebug: 185ba0f801f29565873f7a37e410a812eddaa1ee React-rncore: 90e637179a4ce46643d445a9ef16f53af02a8d25 - React-RuntimeApple: 3df87718b4a8e438b4a02d1d7d47677dfcab35a5 - React-RuntimeCore: e188aa1b0fe0450f3a4e6098f2bb8d4b27c147cf + React-RuntimeApple: 713b7c24b3abed07fa39766b35deaabd679ba48e + React-RuntimeCore: 236d704919077fd3393a26fd0ecbaecc081ec94f React-runtimeexecutor: 2de0d537fc6d5b4a7074587b4459ea331c7e5715 - React-RuntimeHermes: 5aa429111da4c0a62d94b37ad5b5144deb8f49d0 - React-runtimescheduler: c3738ed7f0ba2e51b1612ec8d6cbe104c2b9b59a + React-RuntimeHermes: 3e87ad8c5160c976addacd679774a5e4fdb3c4b4 + React-runtimescheduler: bafaf0af0f68bd761d63ff4de3bf13e391820f79 React-timing: 7ad7dc61dfc93ceb4ec2b3e6d1a6ad9ad3652fe0 - React-utils: d6a3bec920c7fa710e8fb5b7c28d81fe54be8c75 - ReactAppDependencyProvider: 5df090fa3cbfc923c6bd0595b64d5ef6d89f7134 - ReactCodegen: 0c213020a601c6adda74f8826629bff9c6c408d3 - ReactCommon: 7f90ec8358d94ec2a078e11990f6e78542957b11 + React-utils: cf358d29b6802cca3d1bec20a939f2f5d50d40ba + ReactAppDependencyProvider: ad88c80e06f29900f2e6f9ccf1d4cb0bfc3e1bbc + ReactCodegen: 69c7aec61821e1860aaaf959189218ecca40e811 + ReactCommon: ef3e394efce4b78e9214587689c459cf82927368 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba - RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce - RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388 - RNFBAnalytics: fd90ab98caa8e025882db6f06121203aeac9c1f0 - RNFBApp: c766a5582eb4c6d8d2237428620ceb857d6412d4 - RNFBAppCheck: 3d51e284c83e3e9672b3fd661154f3de76805b30 - RNFBAppDistribution: 20279775fff92b1a3c76612531721b16cf8af47a - RNFBAuth: df7af96dfd006256bf12cf891e8df159f5d64b4e - RNFBCrashlytics: e5c1b77c897148f3158be8289b0a39484d409233 - RNFBDatabase: de2e5e2fa455abeee75fcb4f74c0eea861933e9c - RNFBFirestore: 5024886ae0b5ed50c93832f2eb4ec032b86613d1 - RNFBFunctions: 21c864a3bcfb732170958c009e6c5a04f3108006 - RNFBInAppMessaging: 1fdf5ea439d69fc6ebd4a9e2cc9d822129339b58 - RNFBInstallations: 66c16bec2760a1fd8e8f0f0d48c29e3cccd8f9cf - RNFBMessaging: 1476461555bf2433f39f1040170b4e87db1e7649 - RNFBML: 22f27e21df2ece5e4ddb7bac28161600ad7a6b94 - RNFBPerf: 2f650b2dee1c9e663d68384171fcc1bac1c3506c - RNFBRemoteConfig: 2ddbb3d98653ec93ddaa744775b0e34d7a78c1ea - RNFBStorage: 51613d33a1f6266d098f02b4dcd09e18a73338ba + RNCAsyncStorage: 481acf401089f312189e100815088ea5dafc583c + RNDeviceInfo: 53f9c84e28e854a308d3e548e25ef120b4615531 + RNFBAnalytics: d039a56f8487b7a3db9f31004f43b1165864ed08 + RNFBApp: 3934cbcee98905ea91ad8bf84299c35dfdba3ebc + RNFBAppCheck: b1e31fe173f528cd5aa49267ff1df81fa9023ea8 + RNFBAppDistribution: 975475ae26cf2ca0051b5676e803d66170d76602 + RNFBAuth: 2ada0ee44825c4080b21ce8fe9368ac2f465d4b4 + RNFBCrashlytics: 29f98d3dac6607d39fae545e3700de1f02271b2a + RNFBDatabase: 8010f549588876215b65e4f094d4c8f4df1f4799 + RNFBFirestore: 575ae60db2fadb932adebb0de3657056b121fe84 + RNFBFunctions: 4c17097d9f8f6c6e7eee1728b9dd41b917c65162 + RNFBInAppMessaging: 97f939d2d7ca53ea1c3df145530c7d759af4f74b + RNFBInstallations: 0cdbc6c6b69e3fe437cec162b53d42159b394830 + RNFBMessaging: e44ee2e71c6fc219a779629c5815dafe6d7d8ae7 + RNFBML: be5f162afc44dd62a58d33159aa2382b67207d34 + RNFBPerf: d0bd0d375f68a4d3658601f82a65fccc8937bc59 + RNFBRemoteConfig: 64d23bb1d2dd1fcb64dddb2da4018b71280736fc + RNFBStorage: 1d7b20110c074835bac1fda54ad70cd5e20cdf09 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: 3bb1ee33b5133befbd33872601fa46efdd48e841 + Yoga: 66a9fd80007d5d5fce19d1676ce17b4d5e16e9b1 PODFILE CHECKSUM: 3abe8cfe7b06f24b788e90bea320d8ae6ea6d11a diff --git a/tests/local-tests/functions/streaming-callable.tsx b/tests/local-tests/functions/streaming-callable.tsx new file mode 100644 index 0000000000..a5d08db078 --- /dev/null +++ b/tests/local-tests/functions/streaming-callable.tsx @@ -0,0 +1,226 @@ +import React, { useState } from 'react'; +import { Button, Text, View, ScrollView, StyleSheet, Platform } from 'react-native'; +import { + getFunctions, + connectFunctionsEmulator, + httpsCallable, + httpsCallableFromUrl, +} from '@react-native-firebase/functions'; + +const functions = getFunctions(); +connectFunctionsEmulator(functions, 'localhost', 5001); + +export function StreamingCallableTestComponent(): React.JSX.Element { + const [output, setOutput] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + + const addOutput = (message: string) => { + setOutput(prev => prev + message + '\n'); + }; + + const testBasicStream = async () => { + try { + setIsStreaming(true); + setOutput(''); + addOutput('Starting basic stream test...'); + + const callable = httpsCallable(functions, 'testStreamingCallable') as any; + const { stream, data } = await callable.stream({ count: 5, delay: 500 }); + + for await (const chunk of stream) { + addOutput(`Chunk: ${JSON.stringify(chunk)}`); + } + + const result = await data; + addOutput(`Final result: ${JSON.stringify(result)}`); + addOutput('✅ Stream completed'); + } catch (e: any) { + addOutput(`❌ Error: ${e.message}`); + } finally { + setIsStreaming(false); + } + }; + + const testProgressStream = async () => { + try { + setIsStreaming(true); + setOutput(''); + addOutput('Starting progress stream test...'); + + const callable = httpsCallable(functions, 'testProgressStream') as any; + const { stream, data } = await callable.stream({ task: 'TestTask' }); + + for await (const chunk of stream) { + if (chunk.progress !== undefined) { + addOutput(`Progress: ${chunk.progress}% - ${chunk.status}`); + } else { + addOutput(`Data: ${JSON.stringify(chunk)}`); + } + } + + const result = await data; + addOutput(`Final result: ${JSON.stringify(result)}`); + addOutput('✅ Progress stream completed'); + } catch (e: any) { + addOutput(`❌ Error: ${e.message}`); + } finally { + setIsStreaming(false); + } + }; + + const testComplexDataStream = async () => { + try { + setIsStreaming(true); + setOutput(''); + addOutput('Starting complex data stream test...'); + + const callable = httpsCallable(functions, 'testComplexDataStream') as any; + const { stream, data } = await callable.stream({}); + + for await (const chunk of stream) { + addOutput(`Complex data: ${JSON.stringify(chunk, null, 2)}`); + } + + const result = await data; + addOutput(`Final result: ${JSON.stringify(result)}`); + addOutput('✅ Complex data stream completed'); + } catch (e: any) { + addOutput(`❌ Error: ${e.message}`); + } finally { + setIsStreaming(false); + } + }; + + const testStreamFromUrl = async () => { + try { + setIsStreaming(true); + setOutput(''); + addOutput('Starting URL stream test...'); + + const host = Platform.OS === 'android' ? '10.0.2.2' : '127.0.0.1'; + const url = `http://${host}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`; + const callable = httpsCallableFromUrl(functions, url) as any; + const { stream, data } = await callable.stream({ count: 3, delay: 400 }); + + for await (const chunk of stream) { + addOutput(`URL chunk: ${JSON.stringify(chunk)}`); + } + + const result = await data; + addOutput(`Final result: ${JSON.stringify(result)}`); + addOutput('✅ URL stream completed'); + } catch (e: any) { + addOutput(`❌ Error: ${e.message}`); + } finally { + setIsStreaming(false); + } + }; + + const testStreamWithOptions = async () => { + try { + setIsStreaming(true); + setOutput(''); + addOutput('Starting stream with timeout option...'); + + const callable = httpsCallable(functions, 'testStreamingCallable') as any; + const { stream, data } = await callable.stream({ count: 3 }, { timeout: 30000 }); + + for await (const chunk of stream) { + addOutput(`Chunk: ${JSON.stringify(chunk)}`); + } + + const result = await data; + addOutput(`Final result: ${JSON.stringify(result)}`); + addOutput('✅ Options stream completed'); + } catch (e: any) { + addOutput(`❌ Error: ${e.message}`); + } finally { + setIsStreaming(false); + } + }; + + return ( + + Cloud Functions Streaming Tests + Ensure Emulator is running on localhost:5001 + + +