diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index a9a05c703a8..1a12a250b35 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -84,6 +84,8 @@ package com.google.firebase.functions { method public com.google.android.gms.tasks.Task call(Object? data); method public long getTimeout(); method public void setTimeout(long timeout, java.util.concurrent.TimeUnit units); + method public org.reactivestreams.Publisher stream(); + method public org.reactivestreams.Publisher stream(Object? data = null); method public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -93,6 +95,21 @@ package com.google.firebase.functions { field public final Object? data; } + public abstract class StreamResponse { + } + + public static final class StreamResponse.Message extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Message(com.google.firebase.functions.HttpsCallableResult message); + method public com.google.firebase.functions.HttpsCallableResult getMessage(); + property public final com.google.firebase.functions.HttpsCallableResult message; + } + + public static final class StreamResponse.Result extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Result(com.google.firebase.functions.HttpsCallableResult result); + method public com.google.firebase.functions.HttpsCallableResult getResult(); + property public final com.google.firebase.functions.HttpsCallableResult result; + } + } package com.google.firebase.functions.ktx { diff --git a/firebase-functions/firebase-functions.gradle.kts b/firebase-functions/firebase-functions.gradle.kts index 7ec958bdd79..08a797112b9 100644 --- a/firebase-functions/firebase-functions.gradle.kts +++ b/firebase-functions/firebase-functions.gradle.kts @@ -112,6 +112,8 @@ dependencies { implementation(libs.okhttp) implementation(libs.playservices.base) implementation(libs.playservices.basement) + implementation(libs.reactive.streams) + api(libs.playservices.tasks) kapt(libs.autovalue) @@ -131,6 +133,7 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.kotlinx.coroutines.reactive) androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.dexmaker) kapt("com.google.dagger:dagger-android-processor:2.43.2") diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index fed5a371b89..f26d6615d68 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -14,6 +14,16 @@ const assert = require('assert'); const functions = require('firebase-functions'); +const functionsV2 = require('firebase-functions/v2'); + +/** + * Pauses the execution for a specified amount of time. + * @param {number} ms - The number of milliseconds to sleep. + * @return {Promise} A promise that resolves after the specified time. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { @@ -122,3 +132,100 @@ exports.timeoutTest = functions.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); + +const streamData = ['hello', 'world', 'this', 'is', 'cool']; + +/** + * Generates chunks of text asynchronously, yielding one chunk at a time. + * @async + * @generator + * @yields {string} A chunk of text from the data array. + */ +async function* generateText() { + for (const chunk of streamData) { + yield chunk; + await sleep(100); + } +} + +exports.genStream = functionsV2.https.onCall(async (request, response) => { + if (request.acceptsStreaming) { + for await (const chunk of generateText()) { + response.sendChunk(chunk); + } + } + return streamData.join(' '); +}); + +exports.genStreamError = functionsV2.https.onCall( + async (request, response) => { + // Note: The functions backend does not pass the error message to the + // client at this time. + throw Error("BOOM") + }); + +const weatherForecasts = { + Toronto: { conditions: 'snowy', temperature: 25 }, + London: { conditions: 'rainy', temperature: 50 }, + Dubai: { conditions: 'sunny', temperature: 75 } +}; + +/** + * Generates weather forecasts asynchronously for the given locations. + * @async + * @generator + * @param {Array<{name: string}>} locations - An array of location objects. + */ +async function* generateForecast(locations) { + for (const location of locations) { + yield { 'location': location, ...weatherForecasts[location.name] }; + await sleep(100); + } +}; + +exports.genStreamWeather = functionsV2.https.onCall( + async (request, response) => { + const locations = request.data && request.data.data? + request.data.data: []; + const forecasts = []; + if (request.acceptsStreaming) { + for await (const chunk of generateForecast(locations)) { + forecasts.push(chunk); + response.sendChunk(chunk); + } + } + return {forecasts}; + }); + +exports.genStreamEmpty = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Send no chunks + } + // Implicitly return null. + } +); + +exports.genStreamResultOnly = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Do not send any chunks. + } + return "Only a result"; + } +); + +exports.genStreamLargeData = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + const largeString = 'A'.repeat(10000); + const chunkSize = 1024; + for (let i = 0; i < largeString.length; i += chunkSize) { + const chunk = largeString.substring(i, i + chunkSize); + response.sendChunk(chunk); + await sleep(100); + } + } + return "Stream Completed"; + } +); diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt new file mode 100644 index 00000000000..e0de5cc2262 --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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. + */ + +package com.google.firebase.functions + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Firebase +import com.google.firebase.initialize +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var functions: FirebaseFunctions + + @Before + fun setup() { + Firebase.initialize(ApplicationProvider.getApplicationContext()) + functions = Firebase.functions + } + + internal class StreamSubscriber : Subscriber { + internal val messages = mutableListOf() + internal var result: StreamResponse.Result? = null + internal var throwable: Throwable? = null + internal var isComplete = false + internal lateinit var subscription: Subscription + + override fun onSubscribe(subscription: Subscription) { + this.subscription = subscription + subscription.request(Long.MAX_VALUE) + } + + override fun onNext(streamResponse: StreamResponse) { + if (streamResponse is StreamResponse.Message) { + messages.add(streamResponse) + } else { + result = streamResponse as StreamResponse.Result + } + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true + } + } + + @Test + fun genStream_withPublisher_receivesMessagesAndFinalResult() = runBlocking { + val input = mapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStream") + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages.map { it.message.data.toString() }) + .containsExactly("hello", "world", "this", "is", "cool") + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("hello world this is cool") + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() + } + + @Test + fun genStream_withFlow_receivesMessagesAndFinalResult() = runBlocking { + val input = mapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStream") + var isComplete = false + var throwable: Throwable? = null + val messages = mutableListOf() + var result: StreamResponse.Result? = null + + val flow = function.stream(input).asFlow() + try { + withTimeout(1000) { + flow.collect { response -> + if (response is StreamResponse.Message) { + messages.add(response) + } else { + result = response as StreamResponse.Result + } + } + } + isComplete = true + } catch (e: Throwable) { + throwable = e + } + + assertThat(messages.map { it.message.data.toString() }) + .containsExactly("hello", "world", "this", "is", "cool") + assertThat(result).isNotNull() + assertThat(result!!.result.data.toString()).isEqualTo("hello world this is cool") + assertThat(throwable).isNull() + assertThat(isComplete).isTrue() + } + + @Test + fun genStreamError_receivesError() = runBlocking { + val input = mapOf("data" to "test error") + val function = + functions.getHttpsCallable("genStreamError").withTimeout(2000, TimeUnit.MILLISECONDS) + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + withTimeout(2000) { + while (subscriber.throwable == null) { + delay(100) + } + } + + assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) + } + + @Test + fun genStreamWeather_receivesWeatherForecasts() = runBlocking { + val inputData = listOf(mapOf("name" to "Toronto"), mapOf("name" to "London")) + val input = mapOf("data" to inputData) + + val function = functions.getHttpsCallable("genStreamWeather") + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + + assertThat(subscriber.messages.map { it.message.data.toString() }) + .containsExactly( + "{temperature=25, location={name=Toronto}, conditions=snowy}", + "{temperature=50, location={name=London}, conditions=rainy}" + ) + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).contains("forecasts") + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() + } + + @Test + fun genStreamEmpty_receivesNoMessages() = runBlocking { + val function = functions.getHttpsCallable("genStreamEmpty") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test")).subscribe(subscriber) + + withTimeout(2000) { delay(500) } + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNull() + } + + @Test + fun genStreamResultOnly_receivesOnlyResult() = runBlocking { + val function = functions.getHttpsCallable("genStreamResultOnly") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test")).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Only a result") + } + + @Test + fun genStreamLargeData_receivesMultipleChunks() = runBlocking { + val function = functions.getHttpsCallable("genStreamLargeData") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test large data")).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages).isNotEmpty() + assertThat(subscriber.messages.size).isEqualTo(10) + val receivedString = + subscriber.messages.joinToString(separator = "") { it.message.data.toString() } + val expectedString = "A".repeat(10000) + assertThat(receivedString.length).isEqualTo(10000) + assertThat(receivedString).isEqualTo(expectedString) + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Stream Completed") + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 824670c4346..8839763c4a3 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -45,6 +45,7 @@ import okhttp3.RequestBody import okhttp3.Response import org.json.JSONException import org.json.JSONObject +import org.reactivestreams.Publisher /** FirebaseFunctions lets you call Cloud Functions for Firebase. */ public class FirebaseFunctions @@ -311,6 +312,21 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions + ): Publisher = stream(getURL(name), data, options) + + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) + } + + return PublisherStream(url, data, options, client, this.serializer, task, executor) + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 88db9db4ee4..215722584ba 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -17,6 +17,7 @@ import androidx.annotation.VisibleForTesting import com.google.android.gms.tasks.Task import java.net.URL import java.util.concurrent.TimeUnit +import org.reactivestreams.Publisher /** A reference to a particular Callable HTTPS trigger in Cloud Functions. */ public class HttpsCallableReference { @@ -61,10 +62,8 @@ public class HttpsCallableReference { * * * Any primitive type, including null, int, long, float, and boolean. * * [String] - * * [List&lt;?&gt;][java.util.List], where the contained objects are also one of these - * types. - * * [Map&lt;String, ?&gt;>][java.util.Map], where the values are also one of these - * types. + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. * * [org.json.JSONArray] * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] @@ -125,6 +124,55 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint. + * + * The data passed into the trigger can be any of the following types: + * + * * Any primitive type, including null, int, long, float, and boolean. + * * [String] + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned streamResponse fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * @param data Parameters to pass to the endpoint. Defaults to `null` if not provided. + * @return [Publisher] that will emit intermediate data, and the final result, as it is generated + * by the function. + * @see org.json.JSONArray + * + * @see org.json.JSONObject + * + * @see java.io.IOException + * + * @see FirebaseFunctionsException + */ + @JvmOverloads + public fun stream(data: Any? = null): Publisher { + return if (name != null) { + functionsClient.stream(name, data, options) + } else { + functionsClient.stream(requireNotNull(url), data, options) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt new file mode 100644 index 00000000000..6fc6a9d657c --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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. + */ + +package com.google.firebase.functions + +import com.google.android.gms.tasks.Task +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.InterruptedIOException +import java.net.URL +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicLong +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.json.JSONObject +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription + +internal class PublisherStream( + private val url: URL, + private val data: Any?, + private val options: HttpsCallOptions, + private val client: OkHttpClient, + private val serializer: Serializer, + private val contextTask: Task, + private val executor: Executor +) : Publisher { + + private val subscribers = ConcurrentLinkedQueue, AtomicLong>>() + private var activeCall: Call? = null + @Volatile private var isStreamingStarted = false + @Volatile private var isCompleted = false + private val messageQueue = ConcurrentLinkedQueue() + + override fun subscribe(subscriber: Subscriber) { + synchronized(this) { + if (isCompleted) { + subscriber.onError( + FirebaseFunctionsException( + "Cannot subscribe: Streaming has already completed.", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + return + } + subscribers.add(subscriber to AtomicLong(0)) + } + + subscriber.onSubscribe( + object : Subscription { + override fun request(n: Long) { + if (n <= 0) { + subscriber.onError(IllegalArgumentException("Requested messages must be positive.")) + return + } + + synchronized(this@PublisherStream) { + if (isCompleted) return + + val subscriberEntry = subscribers.find { it.first == subscriber } + subscriberEntry?.second?.addAndGet(n) + dispatchMessages() + if (!isStreamingStarted) { + isStreamingStarted = true + startStreaming() + } + } + } + + override fun cancel() { + synchronized(this@PublisherStream) { + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val pair = iterator.next() + if (pair.first == subscriber) { + iterator.remove() + } + } + if (subscribers.isEmpty()) { + cancelStream() + } + } + } + } + ) + } + + private fun startStreaming() { + contextTask.addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + notifyError( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + return@addOnCompleteListener + } + + val context = contextTask.result + val configuredClient = options.apply(client) + val requestBody = + RequestBody.create( + MediaType.parse("application/json"), + JSONObject(mapOf("data" to serializer.encode(data))).toString() + ) + val requestBuilder = + Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") + context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } + context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } + context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } + val request = requestBuilder.build() + val call = configuredClient.newCall(request) + activeCall = call + + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + val code: FirebaseFunctionsException.Code = + if (e is InterruptedIOException) { + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED + } else { + FirebaseFunctionsException.Code.INTERNAL + } + notifyError(FirebaseFunctionsException(code.name, code, null, e)) + } + + override fun onResponse(call: Call, response: Response) { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream) + } else { + notifyError( + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } + } + } + ) + } + } + + private fun cancelStream() { + activeCall?.cancel() + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + } + + private fun processSSEStream(inputStream: InputStream) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + val eventBuffer = StringBuilder() + reader.lineSequence().forEach { line -> + if (line.isBlank()) { + processEvent(eventBuffer.toString()) + eventBuffer.clear() + } else { + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + eventBuffer.append(dataChunk.trim()).append("\n") + } + } + } catch (e: Exception) { + notifyError( + FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + } + + private fun processEvent(dataChunk: String) { + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> { + serializer.decode(json.opt("message"))?.let { + messageQueue.add(StreamResponse.Message(message = HttpsCallableResult(it))) + } + dispatchMessages() + } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + messageQueue.add(StreamResponse.Result(result = HttpsCallableResult(it))) + dispatchMessages() + notifyComplete() + } + } + } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + + private fun dispatchMessages() { + synchronized(this) { + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val (subscriber, requestedCount) = iterator.next() + while (requestedCount.get() > 0 && messageQueue.isNotEmpty()) { + subscriber.onNext(messageQueue.poll()) + requestedCount.decrementAndGet() + } + } + } + } + + private fun notifyError(e: Throwable) { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> + try { + subscriber.onError(e) + } catch (ignored: Exception) {} + } + subscribers.clear() + messageQueue.clear() + } + } + + private fun notifyComplete() { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } + subscribers.clear() + messageQueue.clear() + } + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val errorMessage: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + errorMessage = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + errorMessage, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt new file mode 100644 index 00000000000..123f804614d --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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. + */ + +package com.google.firebase.functions + +/** + * Represents a response from a Server-Sent Event (SSE) stream. + * + * The SSE stream consists of two types of responses: + * - [Message]: Represents an intermediate event pushed from the server. + * - [Result]: Represents the final response that signifies the stream has ended. + */ +public abstract class StreamResponse private constructor() { + + /** + * An event message received during the stream. + * + * Messages are intermediate data chunks sent by the server while processing a request. + * + * Example SSE format: + * ```json + * data: { "message": { "chunk": "foo" } } + * ``` + * + * @property message the intermediate data received from the server. + */ + public class Message(public val message: HttpsCallableResult) : StreamResponse() + + /** + * The final result of the computation, marking the end of the stream. + * + * Unlike [Message], which represents intermediate data chunks, [Result] contains the complete + * computation output. If clients only care about the final result, they can process this type + * alone and ignore intermediate messages. + * + * Example SSE format: + * ```json + * data: { "result": { "text": "foo bar" } } + * ``` + * + * @property result the final computed result received from the server. + */ + public class Result(public val result: HttpsCallableResult) : StreamResponse() +}