Skip to content

Commit 1404359

Browse files
committed
chore(web): add support for abort signal
1 parent 06c1c9c commit 1404359

6 files changed

Lines changed: 163 additions & 24 deletions

File tree

.github/workflows/scripts/functions/src/index.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,30 @@ export const testFunctionCustomRegion = functions.https.onCall(
3333
export const testFunctionTimeout = functions.https.onCall((req, res) => {
3434
const data = req.data
3535
console.log(JSON.stringify({ data }));
36-
return new Promise((resolve, reject) => {
37-
if (data && data.testTimeout) {
38-
setTimeout(
39-
() => resolve({ timeLimit: 'exceeded' }),
40-
parseInt(data.testTimeout, 10)
41-
);
42-
} else {
43-
reject(
44-
new functions.https.HttpsError(
45-
'invalid-argument',
46-
'testTimeout must be provided.'
47-
)
48-
);
49-
}
36+
37+
const timeoutMs = parseInt(data?.testTimeout, 10);
38+
39+
if (isNaN(timeoutMs)) {
40+
throw new functions.https.HttpsError(
41+
'invalid-argument',
42+
'testTimeout must be provided.'
43+
);
44+
}
45+
46+
if (req.acceptsStreaming) {
47+
setTimeout(() => {
48+
res?.sendChunk({ timeLimit: 'exceeded' });
49+
}, timeoutMs);
50+
51+
return new Promise((resolve) => {
52+
setTimeout(resolve, timeoutMs + 100);
53+
});
54+
}
55+
56+
return new Promise((resolve) => {
57+
setTimeout(() => resolve({ timeLimit: 'exceeded' }), timeoutMs);
5058
});
59+
5160
});
5261

5362
// For e2e testing errors & return values.

packages/cloud_functions/cloud_functions/lib/cloud_functions.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import 'package:firebase_core_platform_interface/firebase_core_platform_interfac
1515
import 'package:flutter/foundation.dart';
1616

1717
export 'package:cloud_functions_platform_interface/cloud_functions_platform_interface.dart'
18-
show HttpsCallableOptions, FirebaseFunctionsException;
18+
show
19+
HttpsCallableOptions,
20+
FirebaseFunctionsException,
21+
AbortSignal,
22+
TimeLimit,
23+
Abort,
24+
Any;
1925

2026
part 'src/firebase_functions.dart';
2127
part 'src/https_callable.dart';

packages/cloud_functions/cloud_functions_platform_interface/lib/src/https_callable_options.dart

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,72 @@ class HttpsCallableOptions {
1010
/// Defaults [limitedUseAppCheckToken] to `false`
1111
HttpsCallableOptions(
1212
{this.timeout = const Duration(seconds: 60),
13-
this.limitedUseAppCheckToken = false});
13+
this.limitedUseAppCheckToken = false,
14+
this.webAbortSignal});
1415

1516
/// Returns the timeout for this instance
1617
Duration timeout;
1718

1819
/// Sets whether or not to use limited-use App Check tokens when invoking the associated function.
1920
bool limitedUseAppCheckToken;
21+
22+
/// An AbortSignal that can be used to cancel the streaming response.
23+
/// When the signal is aborted, the underlying HTTP connection will be terminated.
24+
AbortSignal? webAbortSignal;
25+
}
26+
27+
/// Represents a base class for encapsulating abort signals.
28+
sealed class AbortSignal {}
29+
30+
/// Creates an [AbortSignal] that will automatically abort after a specified [time].
31+
///
32+
/// This is equivalent to calling `AbortSignal.timeout(ms)` in the Web SDK.
33+
///
34+
/// Typically used to cancel long-running operations after a timeout duration.
35+
///
36+
/// Example:
37+
/// ```dart
38+
/// final signal = HttpsCallableOptions(webAbortSignal: TimeLimit(Duration(seconds: 10)));
39+
/// ```
40+
class TimeLimit extends AbortSignal {
41+
final Duration time;
42+
TimeLimit(this.time);
43+
}
44+
45+
/// Creates an [AbortSignal] that is immediately aborted with an optional [reason].
46+
///
47+
/// This is equivalent to calling `AbortSignal.abort(reason)` in the Web SDK.
48+
///
49+
/// Useful when you want to explicitly cancel a callable before it begins, or to provide
50+
/// a specific reason for cancellation.
51+
///
52+
/// Example:
53+
/// ```dart
54+
/// final signal = HttpsCallableOptions(webAbortSignal: Abort('User exited'));
55+
/// ```
56+
57+
class Abort extends AbortSignal {
58+
final Object? reason;
59+
Abort([this.reason]);
60+
}
61+
62+
/// Creates an [AbortSignal] that is aborted when **any** of the provided [signals] is aborted.
63+
///
64+
/// This is equivalent to calling `AbortSignal.any([...])` in the Web SDK.
65+
///
66+
/// Useful for combining multiple abort conditions.
67+
///
68+
/// Example:
69+
/// ```dart
70+
/// final signal = HttpsCallableOptions(
71+
/// webAbortSignal: Any([
72+
/// TimeLimit(Duration(seconds: 10)),
73+
/// Abort('User cancelled'),
74+
/// ]),
75+
/// );
76+
/// ```
77+
78+
class Any extends AbortSignal {
79+
final List<AbortSignal> signals;
80+
Any(this.signals);
2081
}

packages/cloud_functions/cloud_functions_web/lib/https_callable_web.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:cloud_functions_web/interop/functions_interop.dart' as interop;
1111

1212
import 'interop/functions.dart' as functions_interop;
1313
import 'utils.dart';
14+
import 'package:web/web.dart' as web;
1415

1516
/// A web specific implementation of [HttpsCallable].
1617
class HttpsCallableWeb extends HttpsCallablePlatform {
@@ -77,9 +78,14 @@ class HttpsCallableWeb extends HttpsCallablePlatform {
7778
}
7879

7980
final JSAny? parametersJS = parameters?.jsify();
81+
web.AbortSignal? signal;
82+
if (options.webAbortSignal != null) {
83+
signal = _createJsAbortSignal(options.webAbortSignal!);
84+
}
8085
interop.HttpsCallableStreamOptions callableStreamOptions =
8186
interop.HttpsCallableStreamOptions(
82-
limitedUseAppCheckTokens: options.limitedUseAppCheckToken.toJS);
87+
limitedUseAppCheckTokens: options.limitedUseAppCheckToken.toJS,
88+
signal: signal);
8389
try {
8490
await for (final value
8591
in callable.stream(parametersJS, callableStreamOptions)) {
@@ -89,4 +95,20 @@ class HttpsCallableWeb extends HttpsCallablePlatform {
8995
throw convertFirebaseFunctionsException(e as JSObject, s);
9096
}
9197
}
98+
99+
web.AbortSignal _createJsAbortSignal(AbortSignal signal) {
100+
try {
101+
switch (signal) {
102+
case TimeLimit(:final time):
103+
return web.AbortSignal.timeout(time.inMilliseconds);
104+
case Abort(:final reason):
105+
return web.AbortSignal.abort(reason.jsify());
106+
case Any(:final signals):
107+
final jsSignals = signals.map(_createJsAbortSignal).toList().toJS;
108+
return web.AbortSignal.any(jsSignals);
109+
}
110+
} catch (e, s) {
111+
throw convertFirebaseFunctionsException(e as JSObject, s);
112+
}
113+
}
92114
}

packages/cloud_functions/cloud_functions_web/lib/interop/functions_interop.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ extension HttpsCallableStreamResultJsImplExtension
102102
@anonymous
103103
abstract class HttpsCallableStreamOptions {
104104
external factory HttpsCallableStreamOptions(
105-
{JSBoolean? limitedUseAppCheckTokens});
105+
{JSBoolean? limitedUseAppCheckTokens, web.AbortSignal? signal});
106106
}
107107

108108
extension HttpsCallableStreamOptionsExtension on HttpsCallableStreamOptions {
109109
external JSBoolean? get limitedUseAppCheckTokens;
110110
external set limitedUseAppCheckTokens(JSBoolean? t);
111+
external web.AbortSignal? signal;
112+
external set siganl(web.AbortSignal? s);
111113
}
112114

113115
extension type JsAsyncIterator<T extends JSAny>._(JSObject _)

tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:async';
56
import 'dart:io';
67

78
import 'package:cloud_functions/cloud_functions.dart';
@@ -354,7 +355,8 @@ void main() {
354355
});
355356

356357
test('accepts a [List]', () async {
357-
final stream = callable.stream(data.list).where((event) => event is Chunk);
358+
final stream =
359+
callable.stream(data.list).where((event) => event is Chunk);
358360
await expectLater(
359361
stream,
360362
emits(
@@ -366,17 +368,54 @@ void main() {
366368

367369
test('accepts a deeply nested [Map]', () async {
368370
final stream = callable.stream({
369-
'type': 'deepMap',
370-
'inputData': data.deepMap,
371-
}).where((event) => event is Chunk);
371+
'type': 'deepMap',
372+
'inputData': data.deepMap,
373+
}).where((event) => event is Chunk);
372374
await expectLater(
373375
stream,
374376
emits(
375-
isA<Chunk>()
376-
.having((e) => e.partialData, 'partialData', equals(data.deepMap)),
377+
isA<Chunk>().having(
378+
(e) => e.partialData,
379+
'partialData',
380+
equals(data.deepMap),
381+
),
377382
),
378383
);
379384
});
385+
386+
test(
387+
'times out when aborted with TimeLimit signal',
388+
() async {
389+
final instance = FirebaseFunctions.instance;
390+
instance.useFunctionsEmulator('localhost', 5001);
391+
392+
final completer = Completer<void>();
393+
394+
final timeoutCallable = FirebaseFunctions.instance.httpsCallable(
395+
kTestFunctionTimeout,
396+
options: HttpsCallableOptions(
397+
webAbortSignal: TimeLimit(const Duration(seconds: 3)),
398+
),
399+
);
400+
401+
timeoutCallable.stream().listen(
402+
(data) {
403+
completer.completeError('Should have thrown');
404+
},
405+
onError: (error) {
406+
if (error is FirebaseFunctionsException) {
407+
expect(error.code, equals('deadline-exceeded'));
408+
completer.complete();
409+
} else {
410+
completer.completeError('Unexpected error type: $error');
411+
}
412+
},
413+
);
414+
415+
await completer.future;
416+
},
417+
skip: !kIsWeb,
418+
);
380419
});
381420
});
382421
}

0 commit comments

Comments
 (0)