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..0b186d606f 100644 --- a/packages/functions/__tests__/functions.test.ts +++ b/packages/functions/__tests__/functions.test.ts @@ -7,6 +7,7 @@ import { httpsCallable, httpsCallableFromUrl, HttpsErrorCode, + type HttpsCallableOptions, type HttpsCallable as HttpsCallableType, type FunctionsModule, @@ -77,6 +78,38 @@ 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(); + }); }); }); @@ -101,6 +134,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 +169,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 +258,24 @@ 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..58ef19a24d 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,211 @@ 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..3cfefd6be9 100644 --- a/packages/functions/e2e/functions.e2e.js +++ b/packages/functions/e2e/functions.e2e.js @@ -412,6 +412,123 @@ 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 +891,260 @@ 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/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..b86ca84f3e 100644 --- a/packages/functions/lib/namespaced.ts +++ b/packages/functions/lib/namespaced.ts @@ -155,12 +155,25 @@ 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(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 +185,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction: any = (data?: any) => { const nativePromise = this.native.httpsCallable( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -194,6 +207,56 @@ 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 +268,7 @@ class FirebaseFunctionsModule extends FirebaseModule { } } - return (data?: any) => { + const callableFunction: any = (data?: any) => { const nativePromise = this.native.httpsCallableFromUrl( this._useFunctionsEmulatorHost, this._useFunctionsEmulatorPort, @@ -227,6 +290,51 @@ 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 +388,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/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 + + +