diff --git a/.github/workflows/scripts/functions/src/index.ts b/.github/workflows/scripts/functions/src/index.ts index 4d72f60ea0..a8a935ced6 100644 --- a/.github/workflows/scripts/functions/src/index.ts +++ b/.github/workflows/scripts/functions/src/index.ts @@ -31,3 +31,10 @@ export { fetchAppCheckTokenV2 } from './fetchAppCheckToken'; export { sendFCM } from './sendFCM'; export { testFetchStream, testFetch } from './vertexaiFunctions'; + +export { + testStreamingCallable, + testProgressStream, + testComplexDataStream, + testStreamWithError, +} 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..de74bad39b --- /dev/null +++ b/.github/workflows/scripts/functions/src/testStreamingCallable.ts @@ -0,0 +1,159 @@ +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', + }; + }, +); diff --git a/packages/functions/__tests__/functions.test.ts b/packages/functions/__tests__/functions.test.ts index d5baa3ebeb..bab63e90b4 100644 --- a/packages/functions/__tests__/functions.test.ts +++ b/packages/functions/__tests__/functions.test.ts @@ -37,6 +37,17 @@ describe('Cloud Functions', function () { globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = false; }); + beforeEach(function () { + // Mock native module for streaming methods + // @ts-ignore test + jest.spyOn(FirebaseModule.prototype, 'native', 'get').mockImplementation(() => { + return { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + }; + }); + }); + it('accessible from firebase.app()', function () { const app = firebase.app(); expect(app.functions).toBeDefined(); @@ -77,10 +88,53 @@ 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 () { + 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 native module for streaming methods + // @ts-ignore test + jest.spyOn(FirebaseModule.prototype, 'native', 'get').mockImplementation(() => { + return { + httpsCallableStream: jest.fn(), + httpsCallableStreamFromUrl: jest.fn(), + }; + }); + }); + it('`getFunctions` function is properly exposed to end user', function () { expect(getFunctions).toBeDefined(); }); @@ -101,6 +155,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 +190,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 () { @@ -191,6 +279,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..b887c8a0c1 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 @@ -109,6 +109,51 @@ public void httpsCallableFromUrl( 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); + + // Convert emulatorPort to Integer (null if not using emulator) + Integer port = emulatorHost != null ? (int) emulatorPort : null; + + module.httpsCallableStream( + appName, region, emulatorHost, port, name, 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); + + // Convert emulatorPort to Integer (null if not using emulator) + Integer port = emulatorHost != null ? (int) emulatorPort : null; + + module.httpsCallableStreamFromUrl( + appName, region, emulatorHost, port, url, callableData, options, (int) listenerId); + } + + @Override + public void removeFunctionsStreaming(String appName, String region, double listenerId) { + module.removeFunctionsStreamingListener((int) listenerId); + } + private void handleFunctionsException(Exception exception, Promise promise) { Object details = null; String code = "UNKNOWN"; 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 index af692704b8..5c862ef30a 100644 --- 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 @@ -18,15 +18,23 @@ */ import android.content.Context; +import android.util.SparseArray; +import com.facebook.react.bridge.Arguments; 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.Tasks; import com.google.firebase.FirebaseApp; import com.google.firebase.functions.FirebaseFunctions; import com.google.firebase.functions.HttpsCallableReference; +import com.google.firebase.functions.StreamResponse; +import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; import io.invertase.firebase.common.UniversalFirebaseModule; import java.net.URL; import java.util.concurrent.TimeUnit; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; @SuppressWarnings("WeakerAccess") public class UniversalFirebaseFunctionsModule extends UniversalFirebaseModule { @@ -34,6 +42,8 @@ public class UniversalFirebaseFunctionsModule extends UniversalFirebaseModule { public static final String CODE_KEY = "code"; public static final String MSG_KEY = "message"; public static final String DETAILS_KEY = "details"; + private static final String STREAMING_EVENT = "functions_streaming_event"; + private static SparseArray functionsStreamingListeners = new SparseArray<>(); UniversalFirebaseFunctionsModule(Context context, String serviceName) { super(context, serviceName); @@ -95,4 +105,231 @@ Task httpsCallableFromUrl( return Tasks.await(httpReference.call(data)).getData(); }); } + + void httpsCallableStream( + String appName, + String region, + String host, + Integer port, + String name, + Object data, + ReadableMap options, + int listenerId) { + getExecutor() + .execute( + () -> { + try { + android.util.Log.d( + "RNFBFunctions", + "httpsCallableStream starting for: " + name + ", listenerId: " + listenerId); + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseFunctions functionsInstance = + FirebaseFunctions.getInstance(firebaseApp, region); + + if (host != null) { + functionsInstance.useEmulator(host, port); + android.util.Log.d("RNFBFunctions", "Using emulator: " + host + ":" + port); + } + + HttpsCallableReference httpReference = functionsInstance.getHttpsCallable(name); + + if (options.hasKey("timeout")) { + httpReference.setTimeout((long) options.getInt("timeout"), TimeUnit.SECONDS); + } + + android.util.Log.d("RNFBFunctions", "About to call .stream() method"); + // Use the Firebase SDK's native .stream() method which returns a Publisher + Publisher publisher = httpReference.stream(data); + android.util.Log.d("RNFBFunctions", "Stream publisher created successfully"); + + // Subscribe to the publisher + publisher.subscribe( + new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + functionsStreamingListeners.put(listenerId, subscription); + s.request(Long.MAX_VALUE); // Request all items + } + + @Override + public void onNext(StreamResponse streamResponse) { + // Emit the stream data as it arrives + emitStreamEvent( + appName, + listenerId, + null, + false, + null); // TODO: Extract data from StreamResponse + } + + @Override + public void onError(Throwable t) { + // Emit error event + android.util.Log.e("RNFBFunctions", "Stream onError for " + name, t); + String errorMsg = t.getMessage() != null ? t.getMessage() : t.toString(); + emitStreamEvent(appName, listenerId, null, true, errorMsg); + removeFunctionsStreamingListener(listenerId); + } + + @Override + public void onComplete() { + // Stream completed - emit done event + android.util.Log.d("RNFBFunctions", "Stream onComplete for " + name); + emitStreamDone(appName, listenerId); + removeFunctionsStreamingListener(listenerId); + } + }); + } catch (Exception e) { + android.util.Log.e( + "RNFBFunctions", "Exception in httpsCallableStream for " + name, e); + String errorMsg = e.getMessage() != null ? e.getMessage() : e.toString(); + emitStreamEvent( + appName, listenerId, null, true, "Stream setup failed: " + errorMsg); + removeFunctionsStreamingListener(listenerId); + } + }); + } + + void httpsCallableStreamFromUrl( + String appName, + String region, + String host, + Integer port, + String url, + Object data, + ReadableMap options, + int listenerId) { + getExecutor() + .execute( + () -> { + try { + android.util.Log.d( + "RNFBFunctions", + "httpsCallableStreamFromUrl starting for: " + + url + + ", listenerId: " + + listenerId); + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseFunctions functionsInstance = + FirebaseFunctions.getInstance(firebaseApp, region); + + if (host != null) { + functionsInstance.useEmulator(host, port); + android.util.Log.d("RNFBFunctions", "Using emulator: " + host + ":" + port); + } + + URL parsedUrl = new URL(url); + HttpsCallableReference httpReference = + functionsInstance.getHttpsCallableFromUrl(parsedUrl); + + if (options.hasKey("timeout")) { + httpReference.setTimeout((long) options.getInt("timeout"), TimeUnit.SECONDS); + } + + android.util.Log.d("RNFBFunctions", "About to call .stream() method on URL"); + // Use the Firebase SDK's native .stream() method which returns a Publisher + Publisher publisher = httpReference.stream(data); + android.util.Log.d( + "RNFBFunctions", "Stream publisher created successfully from URL"); + + // Subscribe to the publisher + publisher.subscribe( + new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + functionsStreamingListeners.put(listenerId, subscription); + s.request(Long.MAX_VALUE); // Request all items + } + + @Override + public void onNext(StreamResponse streamResponse) { + // Emit the stream data as it arrives + emitStreamEvent( + appName, + listenerId, + null, + false, + null); // TODO: Extract data from StreamResponse + } + + @Override + public void onError(Throwable t) { + // Emit error event + android.util.Log.e("RNFBFunctions", "Stream onError for URL: " + url, t); + String errorMsg = t.getMessage() != null ? t.getMessage() : t.toString(); + emitStreamEvent(appName, listenerId, null, true, errorMsg); + removeFunctionsStreamingListener(listenerId); + } + + @Override + public void onComplete() { + // Stream completed - emit done event + android.util.Log.d("RNFBFunctions", "Stream onComplete for URL: " + url); + emitStreamDone(appName, listenerId); + removeFunctionsStreamingListener(listenerId); + } + }); + } catch (Exception e) { + android.util.Log.e( + "RNFBFunctions", "Exception in httpsCallableStreamFromUrl for " + url, e); + String errorMsg = e.getMessage() != null ? e.getMessage() : e.toString(); + emitStreamEvent( + appName, listenerId, null, true, "Stream setup failed: " + errorMsg); + removeFunctionsStreamingListener(listenerId); + } + }); + } + + void removeFunctionsStreamingListener(int listenerId) { + Object listener = functionsStreamingListeners.get(listenerId); + if (listener != null) { + // Cancel the subscription if it's still active + if (listener instanceof Subscription) { + ((Subscription) listener).cancel(); + } + functionsStreamingListeners.remove(listenerId); + } + } + + private void emitStreamEvent( + String appName, int listenerId, Object data, boolean isError, String errorMessage) { + WritableMap eventBody = Arguments.createMap(); + WritableMap body = Arguments.createMap(); + + if (isError) { + body.putString("error", errorMessage); + } else if (data != null) { + // Convert data to WritableMap/Array as needed + // Using RCTConvertFirebase from the common module + io.invertase.firebase.common.RCTConvertFirebase.mapPutValue("data", data, body); + } + + eventBody.putInt("listenerId", listenerId); + eventBody.putMap("body", body); + + FirebaseFunctionsStreamHandler handler = + new FirebaseFunctionsStreamHandler(STREAMING_EVENT, eventBody, appName, listenerId); + + ReactNativeFirebaseEventEmitter.getSharedInstance().sendEvent(handler); + } + + private void emitStreamDone(String appName, int listenerId) { + WritableMap eventBody = Arguments.createMap(); + WritableMap body = Arguments.createMap(); + body.putBoolean("done", true); + + eventBody.putInt("listenerId", listenerId); + eventBody.putMap("body", body); + + FirebaseFunctionsStreamHandler handler = + new FirebaseFunctionsStreamHandler(STREAMING_EVENT, eventBody, appName, listenerId); + + ReactNativeFirebaseEventEmitter.getSharedInstance().sendEvent(handler); + } } 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..9de97ca049 100644 --- a/packages/functions/e2e/functions.e2e.js +++ b/packages/functions/e2e/functions.e2e.js @@ -412,6 +412,116 @@ 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(); + }); + + // Skipped until emulator supports streaming + xit('receives streaming data chunks', function (done) { + this.timeout(10000); + + 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); + const doneEvent = events[events.length - 1]; + doneEvent.done.should.equal(true); + 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 +884,253 @@ 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(); + }); + + // Skipped until emulator supports streaming + xit('receives streaming data chunks', function (done) { + this.timeout(10000); + + 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); + + const doneEvent = events[events.length - 1]; + doneEvent.done.should.equal(true); + + unsubscribe(); + done(); + } catch (e) { + unsubscribe(); + done(e); + } + } + }); + }); + + // Skipped until emulator supports streaming + xit('handles streaming errors correctly', function (done) { + this.timeout(10000); + + 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); + } + } + }); + }); + + // Skipped until emulator supports streaming + xit('cancels stream when unsubscribe is called', function (done) { + this.timeout(10000); + + 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(); + }); + + // Skipped until emulator supports streaming + xit('httpsCallableFromUrl can stream data', function (done) { + this.timeout(10000); + + 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); + 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(); + }); + }); }); }); 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..fc4a546051 100644 --- a/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm +++ b/packages/functions/ios/RNFBFunctions/RNFBFunctionsModule.mm @@ -20,10 +20,17 @@ #import "NativeRNFBTurboFunctions.h" #import "RNFBApp/RCTConvert+FIRApp.h" +#import "RNFBApp/RNFBRCTEventEmitter.h" #import "RNFBApp/RNFBSharedUtils.h" #import "RNFBFunctionsModule.h" +// Import Swift-generated Objective-C header for streaming +#if __has_include("RNFBFunctions-Swift.h") +#import "RNFBFunctions-Swift.h" +#endif + @interface RNFBFunctionsModule () +@property(nonatomic, strong) NSMutableDictionary *streamSubscriptions; @end @implementation RNFBFunctionsModule @@ -31,6 +38,15 @@ @implementation RNFBFunctionsModule #pragma mark Module Setup RCT_EXPORT_MODULE(NativeRNFBTurboFunctions) + +- (instancetype)init { + self = [super init]; + if (self) { + _streamSubscriptions = [NSMutableDictionary dictionary]; + } + return self; +} + #pragma mark - #pragma mark Firebase Functions Methods @@ -241,4 +257,235 @@ - (NSString *)getErrorCodeName:(NSError *)error { return code; } +#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 { + // Extract data from C++ struct BEFORE async block (struct won't be valid in async context) + id callableData = data.data(); + + // Handle nil data + if (callableData == nil) { + callableData = [NSNull null]; + } + + // Extract timeout + std::optional timeout = options.timeout(); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSURL *url = [NSURL URLWithString:customUrlOrRegion]; + FIRApp *firebaseApp = [RCTConvert firAppFromString:appName]; + + FIRFunctions *functions = + (url && url.scheme && url.host) + ? [FIRFunctions functionsForApp:firebaseApp customDomain:customUrlOrRegion] + : [FIRFunctions functionsForApp:firebaseApp region:customUrlOrRegion]; + + if (emulatorHost != nil) { + [functions useEmulatorWithHost:emulatorHost port:(int)emulatorPort]; + } + + NSNumber *listenerIdNumber = @((int)listenerId); + + // Check iOS version and Swift availability + if (@available(iOS 15.0, macOS 12.0, *)) { +#if __has_include("RNFBFunctions-Swift.h") + // Use Firebase SDK's native streaming via Swift wrapper + RNFBFunctionsStreamHandler *handler = [[RNFBFunctionsStreamHandler alloc] init]; + self.streamSubscriptions[listenerIdNumber] = handler; + + double timeoutValue = timeout.has_value() ? timeout.value() : 0; + + [handler startStreamWithFunctions:functions + functionName:name + functionUrl:nil + parameters:callableData + timeout:timeoutValue + eventCallback:^(NSDictionary *event) { + NSDictionary *eventBody = + @{@"listenerId" : listenerIdNumber, @"body" : event}; + [[RNFBRCTEventEmitter shared] + sendEventWithName:@"functions_streaming_event" + body:eventBody]; + + // Remove handler when done + if ([event[@"done"] boolValue]) { + [self.streamSubscriptions removeObjectForKey:listenerIdNumber]; + } + }]; +#else + // Swift bridging not available + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : @"Swift streaming bridge not available. Ensure RNFBFunctionsStreamHandler.swift is included in the Xcode project.", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" body:eventBody]; +#endif + } else { + // iOS version too old + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : @"Streaming requires iOS 15.0+ or macOS 12.0+", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" + body:eventBody]; + } + + } @catch (NSException *exception) { + NSNumber *listenerIdNumber = @((int)listenerId); + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : exception.reason ?: @"Unknown error", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" body:eventBody]; + [self.streamSubscriptions removeObjectForKey:listenerIdNumber]; + } + }); +} + +- (void) + httpsCallableStreamFromUrl:(NSString *)appName + region:(NSString *)customUrlOrRegion + emulatorHost:(NSString *_Nullable)emulatorHost + emulatorPort:(double)emulatorPort + url:(NSString *)urlString + data:(JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlData &) + data + options: + (JS::NativeRNFBTurboFunctions::SpecHttpsCallableStreamFromUrlOptions &) + options + listenerId:(double)listenerId { + // Extract data from C++ struct BEFORE async block (struct won't be valid in async context) + id callableData = data.data(); + + // Handle nil data + if (callableData == nil) { + callableData = [NSNull null]; + } + + // Extract timeout + std::optional timeout = options.timeout(); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + 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 (emulatorHost != nil) { + [functions useEmulatorWithHost:emulatorHost port:(int)emulatorPort]; + } + + NSNumber *listenerIdNumber = @((int)listenerId); + + // Check iOS version and Swift availability + if (@available(iOS 15.0, macOS 12.0, *)) { +#if __has_include("RNFBFunctions-Swift.h") + // Use Firebase SDK's native streaming via Swift wrapper + RNFBFunctionsStreamHandler *handler = [[RNFBFunctionsStreamHandler alloc] init]; + self.streamSubscriptions[listenerIdNumber] = handler; + + double timeoutValue = timeout.has_value() ? timeout.value() : 0; + + [handler startStreamWithFunctions:functions + functionName:nil + functionUrl:urlString + parameters:callableData + timeout:timeoutValue + eventCallback:^(NSDictionary *event) { + NSDictionary *eventBody = + @{@"listenerId" : listenerIdNumber, @"body" : event}; + [[RNFBRCTEventEmitter shared] + sendEventWithName:@"functions_streaming_event" + body:eventBody]; + + // Remove handler when done + if ([event[@"done"] boolValue]) { + [self.streamSubscriptions removeObjectForKey:listenerIdNumber]; + } + }]; +#else + // Swift bridging not available + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : @"Swift streaming bridge not available. Ensure RNFBFunctionsStreamHandler.swift is included in the Xcode project.", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" body:eventBody]; +#endif + } else { + // iOS version too old + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : @"Streaming requires iOS 15.0+ or macOS 12.0+", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" + body:eventBody]; + } + + } @catch (NSException *exception) { + NSNumber *listenerIdNumber = @((int)listenerId); + NSDictionary *eventBody = @{ + @"listenerId" : listenerIdNumber, + @"body" : @{ + @"data" : [NSNull null], + @"error" : exception.reason ?: @"Unknown error", + @"done" : @NO + } + }; + [[RNFBRCTEventEmitter shared] sendEventWithName:@"functions_streaming_event" body:eventBody]; + [self.streamSubscriptions removeObjectForKey:listenerIdNumber]; + } + }); +} + +- (void)removeFunctionsStreaming:(NSString *)appName + region:(NSString *)region + listenerId:(double)listenerId { + NSNumber *listenerIdNumber = @((int)listenerId); + id handler = self.streamSubscriptions[listenerIdNumber]; + + if (handler != nil) { + if (@available(iOS 15.0, macOS 12.0, *)) { +#if __has_include("RNFBFunctions-Swift.h") + if ([handler respondsToSelector:@selector(cancel)]) { + [handler performSelector:@selector(cancel)]; + } +#endif + } + [self.streamSubscriptions removeObjectForKey:listenerIdNumber]; + } +} + @end diff --git a/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift b/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift new file mode 100644 index 0000000000..baf25a8c4b --- /dev/null +++ b/packages/functions/ios/RNFBFunctions/RNFBFunctionsStreamHandler.swift @@ -0,0 +1,196 @@ +/** + * 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 + +/// 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, *) +@objc(RNFBFunctionsStreamHandler) +public class RNFBFunctionsStreamHandler: NSObject { + private var streamTask: Task? + + /// Start streaming from a Firebase Function + /// - Parameters: + /// - functions: Firebase Functions instance + /// - 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( + functions: Functions, + functionName: String?, + functionUrl: String?, + parameters: Any?, + timeout: Double, + eventCallback: @escaping ([AnyHashable: Any]) -> Void + ) { + streamTask = Task { + await performStream( + functions: functions, + functionName: functionName, + functionUrl: functionUrl, + parameters: parameters, + timeout: timeout, + eventCallback: eventCallback + ) + } + } + + /// Cancel the streaming task + @objc public func cancel() { + streamTask?.cancel() + streamTask = nil + } + + private func performStream( + functions: Functions, + functionName: String?, + functionUrl: String?, + parameters: Any?, + timeout: Double, + eventCallback: @escaping ([AnyHashable: Any]) -> Void + ) async { + // Create the callable function reference + var callable: Callable> + + if let functionName = functionName { + callable = functions.httpsCallable(functionName) + } else if let functionUrl = functionUrl, let url = URL(string: functionUrl) { + callable = functions.httpsCallable(url) + } else { + await MainActor.run { + eventCallback([ + "data": NSNull(), + "error": "Either functionName or functionUrl must be provided", + "done": false + ]) + } + return + } + + // Set timeout if provided + if timeout > 0 { + callable.timeoutInterval = timeout / 1000 + } + + do { + // Encode parameters + let encodedParams = AnyEncodable(parameters) + + // Start streaming using Firebase SDK's native stream() method + let stream = try callable.stream(encodedParams) + + // Iterate over stream responses + for try await response in stream { + await MainActor.run { + switch response { + case .message(let message): + // This is a data chunk from sendChunk() + eventCallback([ + "data": message.value ?? NSNull(), + "error": NSNull(), + "done": false + ]) + case .result(let result): + // This is the final result + eventCallback([ + "data": result.value ?? NSNull(), + "error": NSNull(), + "done": true + ]) + } + } + } + } catch { + await MainActor.run { + eventCallback([ + "data": NSNull(), + "error": error.localizedDescription, + "done": false + ]) + } + } + } +} + +// 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/namespaced.ts b/packages/functions/lib/namespaced.ts index 857597108e..512d87b61e 100644 --- a/packages/functions/lib/namespaced.ts +++ b/packages/functions/lib/namespaced.ts @@ -155,12 +155,29 @@ class FirebaseFunctionsModule extends FirebaseModule { _customUrlOrRegion: string; private _useFunctionsEmulatorHost: string | null; private _useFunctionsEmulatorPort: number; + private _id_functions_streaming_event: number; + // TODO: config is app package (FirebaseModule) object to be typed in the future constructor(app: FirebaseApp, config: any, customUrlOrRegion: string | null) { super(app, config, customUrlOrRegion); 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: { listenerId: any }) => { + // @ts-ignore + this.emitter.emit( + // @ts-ignore + this.eventNameForApp(`functions_streaming_event:${event.listenerId}`), + event, + ); + }, + ); } httpsCallable(name: string, options: HttpsCallableOptions = {}) { @@ -172,7 +189,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction: any = (data?: any) => { const nativePromise = this.native.httpsCallable( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -194,6 +211,60 @@ class FirebaseFunctionsModule extends FirebaseModule { ); }); }; + + // Add a streaming helper (callback-based) + // Usage: const stop = functions().httpsCallable('fn').stream(data, (evt) => {...}, options) + callableFunction.stream = ( + data?: any, + onEvent?: (event: any) => void, + streamOptions: HttpsCallableOptions = {}, + ) => { + if (streamOptions.timeout) { + if (isNumber(streamOptions.timeout)) { + streamOptions.timeout = streamOptions.timeout / 1000; + } else { + throw new Error('HttpsCallableOptions.timeout expected a Number in milliseconds'); + } + } + + const listenerId = this._id_functions_streaming_event++; + // @ts-ignore + const eventName = this.eventNameForApp(`functions_streaming_event:${listenerId}`); + + // @ts-ignore + const subscription = this.emitter.addListener(eventName, (event: any) => { + const body = event.body; + if (onEvent) { + onEvent(body); + } + if (body && (body.done || body.error)) { + subscription.remove(); + if (this.native.removeFunctionsStreaming) { + this.native.removeFunctionsStreaming(listenerId); + } + } + }); + + // Start native streaming on both platforms. + // Note: appName and customUrlOrRegion are automatically prepended by the native module wrapper + this.native.httpsCallableStream( + this._useFunctionsEmulatorHost || null, + this._useFunctionsEmulatorPort || -1, + name, + { data }, + streamOptions, + listenerId, + ); + + return () => { + subscription.remove(); + if (this.native.removeFunctionsStreaming) { + this.native.removeFunctionsStreaming(listenerId); + } + }; + }; + + return callableFunction; } httpsCallableFromUrl(url: string, options: HttpsCallableOptions = {}) { @@ -205,7 +276,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction: any = (data?: any) => { const nativePromise = this.native.httpsCallableFromUrl( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -227,6 +298,55 @@ class FirebaseFunctionsModule extends FirebaseModule { ); }); }; + + callableFunction.stream = ( + data?: any, + onEvent?: (event: any) => void, + streamOptions: HttpsCallableOptions = {}, + ) => { + if (streamOptions.timeout) { + if (isNumber(streamOptions.timeout)) { + streamOptions.timeout = streamOptions.timeout / 1000; + } else { + throw new Error('HttpsCallableOptions.timeout expected a Number in milliseconds'); + } + } + + const listenerId = this._id_functions_streaming_event++; + // @ts-ignore + const eventName = this.eventNameForApp(`functions_streaming_event:${listenerId}`); + + // @ts-ignore + const subscription = this.emitter.addListener(eventName, (event: any) => { + const body = event.body; + if (onEvent) { + onEvent(body); + } + if (body && (body.done || body.error)) { + subscription.remove(); + if (this.native.removeFunctionsStreaming) { + this.native.removeFunctionsStreaming(listenerId); + } + } + }); + + this.native.httpsCallableStreamFromUrl( + this._useFunctionsEmulatorHost || null, + this._useFunctionsEmulatorPort || -1, + url, + { data }, + streamOptions, + listenerId, + ); + + return () => { + subscription.remove(); + if (this.native.removeFunctionsStreaming) { + this.native.removeFunctionsStreaming(listenerId); + } + }; + }; + return callableFunction; } useFunctionsEmulator(origin: string): void { @@ -280,7 +400,7 @@ export default 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 16463d1b62..4c12a5442a 100644 --- a/packages/functions/lib/types/functions.ts +++ b/packages/functions/lib/types/functions.ts @@ -21,8 +21,19 @@ export interface HttpsCallableOptions { timeout?: number; } +export interface StreamEvent { + data?: ResponseData; + error?: string; + done?: boolean; +} + export interface HttpsCallable { (data?: RequestData | null): Promise<{ data: ResponseData }>; + stream( + data?: RequestData | null, + onEvent?: (event: StreamEvent) => void, + options?: HttpsCallableOptions, + ): () => void; } export interface FunctionsModule { @@ -34,6 +45,14 @@ export interface FunctionsModule { url: string, options?: HttpsCallableOptions, ): HttpsCallable; + httpsCallableStream( + name: string, + options?: HttpsCallableOptions, + ): HttpsCallable; + httpsCallableStreamFromUrl( + url: string, + options?: HttpsCallableOptions, + ): HttpsCallable; useFunctionsEmulator(origin: string): void; useEmulator(host: string, port: number): void; } diff --git a/packages/functions/lib/web/RNFBFunctionsModule.ts b/packages/functions/lib/web/RNFBFunctionsModule.ts index 7d4b7b397d..c7b3d5e260 100644 --- a/packages/functions/lib/web/RNFBFunctionsModule.ts +++ b/packages/functions/lib/web/RNFBFunctionsModule.ts @@ -5,6 +5,8 @@ import { httpsCallableFromURL, connectFunctionsEmulator, } from '@react-native-firebase/app/lib/internal/web/firebaseFunctions'; +// @ts-ignore - JS module +import RNFBAppModule from '@react-native-firebase/app/lib/internal/web/RNFBAppModule'; import type { HttpsCallableOptions } from '../index'; import type { NativeError } from '../HttpsError'; @@ -143,4 +145,309 @@ 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 { + // Call the streaming version + const callableWithStream = callable as any; + + if (typeof callableWithStream.stream === 'function') { + const subscription = callableWithStream.stream(data).subscribe({ + next: (chunk: any) => { + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: chunk.data ?? null, + error: null, + done: false, + }, + }); + }, + error: (error: any) => { + const { code, message, details } = error; + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + }, + complete: () => { + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: null, + error: null, + done: true, + }, + }); + }, + }); + + // Store subscription for cleanup if needed + // (Could be extended with unsubscribe support) + if (typeof globalThis !== 'undefined') { + (globalThis as any).__rnfbFunctionsStreamSubscriptions = + (globalThis as any).__rnfbFunctionsStreamSubscriptions || {}; + (globalThis as any).__rnfbFunctionsStreamSubscriptions[listenerId] = subscription; + } + } else { + // Fallback: streaming not supported, emit error + RNFBAppModule.eventsSendEvent('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; + RNFBAppModule.eventsSendEvent('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; + RNFBAppModule.eventsSendEvent('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 { + // Call the streaming version + const callableWithStream = callable as any; + + if (typeof callableWithStream.stream === 'function') { + const subscription = callableWithStream.stream(data).subscribe({ + next: (chunk: any) => { + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: chunk.data ?? null, + error: null, + done: false, + }, + }); + }, + error: (error: any) => { + const { code, message, details } = error; + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + }, + complete: () => { + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: null, + error: null, + done: true, + }, + }); + }, + }); + + // Store subscription for cleanup if needed + if (typeof globalThis !== 'undefined') { + (globalThis as any).__rnfbFunctionsStreamSubscriptions = + (globalThis as any).__rnfbFunctionsStreamSubscriptions || {}; + (globalThis as any).__rnfbFunctionsStreamSubscriptions[listenerId] = subscription; + } + } else { + // Fallback: streaming not supported, emit error + RNFBAppModule.eventsSendEvent('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; + RNFBAppModule.eventsSendEvent('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; + RNFBAppModule.eventsSendEvent('functions_streaming_event', { + listenerId, + body: { + data: null, + error: { + code: code ? code.replace('functions/', '') : 'unknown', + message: message || error.toString(), + details, + }, + done: true, + }, + }); + } + }, }; 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/local-tests/functions/streaming-callable.tsx b/tests/local-tests/functions/streaming-callable.tsx new file mode 100644 index 0000000000..315343e2be --- /dev/null +++ b/tests/local-tests/functions/streaming-callable.tsx @@ -0,0 +1,335 @@ +import React, { useState } from 'react'; +import { Button, Text, View, ScrollView, StyleSheet } from 'react-native'; + +import { getApp } from '@react-native-firebase/app'; +import { + getFunctions, + connectFunctionsEmulator, + httpsCallable, + httpsCallableFromUrl, +} from '@react-native-firebase/functions'; + +const functions = getFunctions(); +connectFunctionsEmulator(functions, 'localhost', 5001); + +export function StreamingCallableTestComponent(): React.JSX.Element { + const [logs, setLogs] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [stopFunction, setStopFunction] = useState<(() => void) | null>(null); + + const addLog = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setLogs(prev => [`[${timestamp}] ${message}`, ...prev].slice(0, 50)); + console.log(`[StreamingTest] ${message}`); + }; + + const clearLogs = () => { + setLogs([]); + }; + + const testBasicStream = async () => { + try { + addLog('🚀 Starting basic streaming test...'); + setIsStreaming(true); + + const functionRunner = httpsCallable(functions, 'testStreamingCallable'); + + // Use the .stream() method + const stop = functionRunner.stream( + { count: 5, delay: 500 }, + event => { + addLog(`📦 Received event: ${JSON.stringify(event)}`); + + if (event.done) { + addLog('✅ Stream completed'); + setIsStreaming(false); + setStopFunction(null); + } else if (event.error) { + addLog(`❌ Stream error: ${event.error}`); + setIsStreaming(false); + setStopFunction(null); + } else if (event.data) { + addLog(`📊 Data chunk: ${JSON.stringify(event.data)}`); + } + }, + ); + + setStopFunction(() => stop); + } catch (e: any) { + addLog(`❌ Error: ${e.message}`); + setIsStreaming(false); + } + }; + + const testProgressStream = async () => { + try { + addLog('📈 Starting progress streaming test...'); + setIsStreaming(true); + + const functionRunner = httpsCallable(functions, 'testProgressStream'); + + const stop = functionRunner.stream({ task: 'TestTask' }, event => { + if (event.done) { + addLog('✅ Progress stream completed'); + setIsStreaming(false); + setStopFunction(null); + } else if (event.error) { + addLog(`❌ Progress error: ${event.error}`); + setIsStreaming(false); + setStopFunction(null); + } else if (event.data) { + const data = event.data; + if (data.progress !== undefined) { + addLog(`⏳ Progress: ${data.progress}% - ${data.status}`); + } else { + addLog(`📊 Progress data: ${JSON.stringify(data)}`); + } + } + }); + + setStopFunction(() => stop); + } catch (e: any) { + addLog(`❌ Error: ${e.message}`); + setIsStreaming(false); + } + }; + + const testComplexDataStream = async () => { + try { + addLog('🔧 Starting complex data streaming test...'); + setIsStreaming(true); + + const functionRunner = httpsCallable(functions, 'testComplexDataStream'); + + const stop = functionRunner.stream({}, event => { + if (event.done) { + addLog('✅ Complex data stream completed'); + setIsStreaming(false); + setStopFunction(null); + } else if (event.error) { + addLog(`❌ Complex data error: ${event.error}`); + setIsStreaming(false); + setStopFunction(null); + } else if (event.data) { + addLog(`🗂️ Complex data: ${JSON.stringify(event.data, null, 2)}`); + } + }); + + setStopFunction(() => stop); + } catch (e: any) { + addLog(`❌ Error: ${e.message}`); + setIsStreaming(false); + } + }; + + const testStreamFromUrl = async () => { + try { + addLog('🌐 Testing httpsCallableFromUrl streaming...'); + setIsStreaming(true); + + // Test httpsCallableFromUrl streaming with modular API + // For emulator: http://localhost:5001/{projectId}/{region}/{functionName} + const url = 'http://localhost:5001/test-project/us-central1/testStreamingCallable'; + const callableRef = httpsCallableFromUrl(functions, url); + + const stop = callableRef.stream({ count: 3, delay: 400 }, event => { + if (event.done) { + addLog('✅ URL stream completed'); + setIsStreaming(false); + setStopFunction(null); + } else if (event.error) { + addLog(`❌ URL stream error: ${event.error}`); + setIsStreaming(false); + setStopFunction(null); + } else if (event.data) { + addLog(`📦 URL data: ${JSON.stringify(event.data)}`); + } + }); + + setStopFunction(() => stop); + } catch (e: any) { + addLog(`❌ Error: ${e.message}`); + setIsStreaming(false); + } + }; + + const testStreamWithOptions = async () => { + try { + addLog('⚙️ Testing stream with timeout option...'); + setIsStreaming(true); + + const callableRef = httpsCallable(functions, 'testStreamingCallable'); + + const stop = callableRef.stream( + { count: 3 }, + event => { + if (event.done) { + addLog('✅ Options stream completed'); + setIsStreaming(false); + setStopFunction(null); + } else if (event.error) { + addLog(`❌ Options stream error: ${event.error}`); + setIsStreaming(false); + setStopFunction(null); + } else if (event.data) { + addLog(`📦 Options data: ${JSON.stringify(event.data)}`); + } + }, + { timeout: 30000 } // 30 second timeout + ); + + setStopFunction(() => stop); + } catch (e: any) { + addLog(`❌ Error: ${e.message}`); + setIsStreaming(false); + } + }; + + const stopStream = () => { + if (stopFunction) { + addLog('🛑 Stopping stream...'); + stopFunction(); + setStopFunction(null); + setIsStreaming(false); + } + }; + + return ( + + 🌊 Cloud Functions Streaming Tests + Ensure Emulator is running on localhost:5001 + + +