Skip to content

[cloud_functions]: HttpsCallable.stream() terminal Result is not dartified on web #18211

@ben-milanko

Description

@ben-milanko

Is there an existing issue for this?

  • I have searched the existing issues.

Which plugins are affected?

Cloud Functions

Which platforms are affected?

Web

Description

On web, HttpsCallable.stream() yields two kinds of events: intermediate Chunks (from response.sendChunk(...) on the server) and a terminal Result (the value the server function returns). Chunks are correctly converted into plain Dart types (Map<String, dynamic>, List, primitives), but the terminal Result's .data is handed back as the raw JSObject / JSAny returned by streamResult.data.toDart, skipping the _dartify helper that is applied to chunks.

Consequence: downstream code that does finalResult is Map<String, dynamic> (or the many common variants such as finalResult as Map, (finalResult as Map)['foo'], jsonEncode(finalResult), etc.) fails on web even though the exact same code works on Android / iOS / macOS, where the native channel already delivers a Dart map. Plugin users end up either writing manual JS-interop dartify() calls in every callsite, or seeing silent empty-map fallbacks (which is how we noticed — a boolean field in the response came back as null on web only, and the client logic branched into an error path).

The fix should mirror what the plugin already does for chunks: run the _dartify helper on the final result before yielding it.

Offending code

packages/cloud_functions/cloud_functions_web/lib/interop/functions.dart, lines 95–107 (same on master and on the released cloud_functions_web 5.1.5):

await for (final value in streamResult.stream.asStream()) {
  // ignore: invalid_runtime_check_with_js_interop_types
  final message = value is JSObject
      ? HttpsCallableStreamResult.getInstance(
          value as functions_interop.HttpsStreamIterableResult,
        ).data                         // <-- goes through _dartify via HttpsCallableStreamResult._fromJsObject
      : value;
  yield {'message': message};
}

final result = await streamResult.data.toDart;
yield {'result': result};              // <-- NOT dartified; raw JSAny

Note that HttpsCallableResult._fromJsObject (used by the non-streaming .call() path) does run _dartify(jsObject.data), so non-streaming callables are unaffected — it is specifically the streaming terminal result that is broken.

Suggested fix

final result = await streamResult.data.toDart;
yield {'result': _dartify(result)};

Happy to open a PR if helpful.

Reproducing the issue

Minimal repro:

Cloud Function (TypeScript, firebase-functions v5+):

import { onCall } from 'firebase-functions/https'

export const pingStream = onCall(async (_req, response) => {
  await response?.sendChunk({ phase: 'working' })
  return { success: true, message: 'done' }
})

Flutter client:

final callable = FirebaseFunctions.instance.httpsCallable('pingStream');
await for (final event in callable.stream<dynamic, dynamic>()) {
  switch (event) {
    case Chunk(:final partialData):
      print('chunk: ${partialData.runtimeType} / $partialData');
    case Result(:final result):
      final data = result.data;
      print('result: ${data.runtimeType} / $data');
      print('is Map<String, dynamic>: ${data is Map<String, dynamic>}');
      print('is Map: ${data is Map}');
  }
}

Observed on web (Chrome, Flutter 3.41.6, cloud_functions 6.2.0):

chunk: _Map<String, dynamic> / {phase: working}
result: JSObject / [object Object]
is Map<String, dynamic>: false
is Map: false

Observed on Android / iOS / macOS (same app, same code):

chunk: _Map<String, dynamic> / {phase: working}
result: _Map<String, dynamic> / {success: true, message: done}
is Map<String, dynamic>: true
is Map: true

Firebase Core version

4.7.0

Flutter Version

3.41.6

Relevant Log Output

Flutter dependencies

Expand Flutter dependencies snippet
- cloud_functions 6.2.0 [cloud_functions_platform_interface cloud_functions_web firebase_core firebase_core_platform_interface flutter]
- cloud_functions_platform_interface 5.8.12
- cloud_functions_web 5.1.5
- firebase_core 4.7.0 [firebase_core_platform_interface firebase_core_web flutter meta]

Additional context and comments

Current workaround on the client side is to conditionally call .dartify() on the result when running on web:

// conditional import — native stub returns `value` unchanged, web variant
// imports `dart:js_interop` and calls `(value as JSAny).dartify()`.
import 'dartify_stub.dart' if (dart.library.js_interop) 'dartify_web.dart';

case Result(:final result):
  yield CloudStreamResult(dartifyCloudFunctionResult(result.data));

…but this really belongs inside cloud_functions_web so every streaming-callable consumer doesn't have to reinvent it.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions