Skip to content

Commit 48eca40

Browse files
committed
Support streaming streaming responses for callable functions.
1 parent de32c24 commit 48eca40

File tree

7 files changed

+354
-29
lines changed

7 files changed

+354
-29
lines changed

common/api-review/functions.api.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ export type FunctionsErrorCodeCore = 'ok' | 'cancelled' | 'unknown' | 'invalid-a
3333
export function getFunctions(app?: FirebaseApp, regionOrCustomDomain?: string): Functions;
3434

3535
// @public
36-
export type HttpsCallable<RequestData = unknown, ResponseData = unknown> = (data?: RequestData | null) => Promise<HttpsCallableResult<ResponseData>>;
36+
export type HttpsCallable<RequestData = unknown, ResponseData = unknown, StreamData = unknown> = {
37+
(data?: RequestData | null): Promise<HttpsCallableResult<ResponseData>>;
38+
stream: (data?: RequestData | null) => Promise<HttpsCallableStreamResult<ResponseData, StreamData>>;
39+
};
3740

3841
// @public
39-
export function httpsCallable<RequestData = unknown, ResponseData = unknown>(functionsInstance: Functions, name: string, options?: HttpsCallableOptions): HttpsCallable<RequestData, ResponseData>;
42+
export function httpsCallable<RequestData = unknown, ResponseData = unknown, StreamData = unknown>(functionsInstance: Functions, name: string, options?: HttpsCallableOptions): HttpsCallable<RequestData, ResponseData, StreamData>;
4043

4144
// @public
42-
export function httpsCallableFromURL<RequestData = unknown, ResponseData = unknown>(functionsInstance: Functions, url: string, options?: HttpsCallableOptions): HttpsCallable<RequestData, ResponseData>;
45+
export function httpsCallableFromURL<RequestData = unknown, ResponseData = unknown, StreamData = unknown>(functionsInstance: Functions, url: string, options?: HttpsCallableOptions): HttpsCallable<RequestData, ResponseData, StreamData>;
4346

4447
// @public
4548
export interface HttpsCallableOptions {
@@ -51,5 +54,13 @@ export interface HttpsCallableResult<ResponseData = unknown> {
5154
readonly data: ResponseData;
5255
}
5356

57+
// @public
58+
export interface HttpsCallableStreamResult<ResponseData = unknown, StreamData = unknown> {
59+
// (undocumented)
60+
readonly data: Promise<ResponseData>;
61+
// (undocumented)
62+
readonly stream: AsyncIterable<StreamData>;
63+
}
64+
5465

5566
```

packages/functions-types/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,21 @@ export interface HttpsCallableResult {
2222
readonly data: any;
2323
}
2424

25+
/**
26+
* An HttpsCallableStreamResult wraps a single streaming result from a function call.
27+
*/
28+
export interface HttpsCallableStreamResult {
29+
readonly data: Promise<any>;
30+
readonly stream: AsyncIterable<any>;
31+
}
32+
2533
/**
2634
* An HttpsCallable is a reference to a "callable" http trigger in
2735
* Google Cloud Functions.
2836
*/
2937
export interface HttpsCallable {
3038
(data?: {} | null): Promise<HttpsCallableResult>;
39+
stream(data?: {} | null): Promise<HttpsCallableStreamResult>;
3140
}
3241

3342
/**

packages/functions/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"test:browser": "karma start --single-run",
3636
"test:browser:debug": "karma start --browsers=Chrome --auto-watch",
3737
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'src/{,!(browser)/**/}*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js",
38-
"test:emulator": "env FIREBASE_FUNCTIONS_EMULATOR_ORIGIN=http://localhost:5005 run-p test:node",
38+
"test:emulator": "env FIREBASE_FUNCTIONS_EMULATOR_ORIGIN=http://127.0.0.1:5005 run-p test:node",
3939
"api-report": "api-extractor run --local --verbose",
4040
"doc": "api-documenter markdown --input temp --output docs",
4141
"build:doc": "yarn build && yarn doc",

packages/functions/src/api.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,12 @@ export function connectFunctionsEmulator(
8686
* @param name - The name of the trigger.
8787
* @public
8888
*/
89-
export function httpsCallable<RequestData = unknown, ResponseData = unknown>(
89+
export function httpsCallable<RequestData = unknown, ResponseData = unknown, StreamData = unknown>(
9090
functionsInstance: Functions,
9191
name: string,
9292
options?: HttpsCallableOptions
93-
): HttpsCallable<RequestData, ResponseData> {
94-
return _httpsCallable<RequestData, ResponseData>(
93+
): HttpsCallable<RequestData, ResponseData, StreamData> {
94+
return _httpsCallable<RequestData, ResponseData, StreamData>(
9595
getModularInstance<FunctionsService>(functionsInstance as FunctionsService),
9696
name,
9797
options
@@ -105,13 +105,14 @@ export function httpsCallable<RequestData = unknown, ResponseData = unknown>(
105105
*/
106106
export function httpsCallableFromURL<
107107
RequestData = unknown,
108-
ResponseData = unknown
108+
ResponseData = unknown,
109+
StreamData = unknown,
109110
>(
110111
functionsInstance: Functions,
111112
url: string,
112113
options?: HttpsCallableOptions
113-
): HttpsCallable<RequestData, ResponseData> {
114-
return _httpsCallableFromURL<RequestData, ResponseData>(
114+
): HttpsCallable<RequestData, ResponseData, StreamData> {
115+
return _httpsCallableFromURL<RequestData, ResponseData, StreamData>(
115116
getModularInstance<FunctionsService>(functionsInstance as FunctionsService),
116117
url,
117118
options

packages/functions/src/callable.test.ts

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,19 @@ describe('Firebase Functions > Call', () => {
9292
Record<string, any>,
9393
{ message: string; code: number; long: number }
9494
>(functions, 'dataTest');
95-
const result = await func(data);
95+
try {
96+
97+
const result = await func(data);
98+
99+
expect(result.data).to.deep.equal({
100+
message: 'stub response',
101+
code: 42,
102+
long: 420
103+
});
104+
} catch (err) {
105+
console.error(err)
106+
}
96107

97-
expect(result.data).to.deep.equal({
98-
message: 'stub response',
99-
code: 42,
100-
long: 420
101-
});
102108
});
103109

104110
it('scalars', async () => {
@@ -226,3 +232,155 @@ describe('Firebase Functions > Call', () => {
226232
await expectError(func(), 'deadline-exceeded', 'deadline-exceeded');
227233
});
228234
});
235+
236+
describe('Firebase Functions > Stream', () => {
237+
let app: FirebaseApp;
238+
const region = 'us-central1';
239+
240+
before(() => {
241+
const useEmulator = !!process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN;
242+
const projectId = useEmulator
243+
? 'functions-integration-test'
244+
: TEST_PROJECT.projectId;
245+
const messagingSenderId = 'messaging-sender-id';
246+
app = makeFakeApp({ projectId, messagingSenderId });
247+
});
248+
249+
it('successfully streams data and resolves final result', async () => {
250+
const functions = createTestService(app, region);
251+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
252+
253+
const mockResponse = new ReadableStream({
254+
start(controller) {
255+
controller.enqueue(new TextEncoder().encode('data: {"message":"Hello"}\n'));
256+
controller.enqueue(new TextEncoder().encode('data: {"message":"World"}\n'));
257+
controller.enqueue(new TextEncoder().encode('data: {"result":"Final Result"}\n'));
258+
controller.close();
259+
}
260+
});
261+
262+
mockFetch.resolves({
263+
body: mockResponse,
264+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
265+
status: 200,
266+
statusText: 'OK',
267+
} as Response);
268+
269+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'streamTest');
270+
const streamResult = await func.stream({});
271+
272+
const messages: string[] = [];
273+
for await (const message of streamResult.stream) {
274+
messages.push(message);
275+
}
276+
277+
expect(messages).to.deep.equal(['Hello', 'World']);
278+
expect(await streamResult.data).to.equal('Final Result');
279+
280+
mockFetch.restore();
281+
});
282+
283+
it('handles network errors', async () => {
284+
const functions = createTestService(app, region);
285+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
286+
287+
mockFetch.rejects(new Error('Network error'));
288+
289+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
290+
const streamResult = await func.stream({});
291+
292+
let errorThrown = false;
293+
try {
294+
for await (const _ of streamResult.stream) {
295+
// This should not execute
296+
}
297+
} catch (error: unknown) {
298+
errorThrown = true;
299+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/internal`);
300+
}
301+
302+
expect(errorThrown).to.be.true;
303+
expect(streamResult.data).to.be.a('promise');
304+
305+
mockFetch.restore();
306+
});
307+
308+
it('handles server-side errors', async () => {
309+
const functions = createTestService(app, region);
310+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
311+
312+
const mockResponse = new ReadableStream({
313+
start(controller) {
314+
controller.enqueue(new TextEncoder().encode('data: {"error":{"status":"INVALID_ARGUMENT","message":"Invalid input"}}\n'));
315+
controller.close();
316+
}
317+
});
318+
319+
mockFetch.resolves({
320+
body: mockResponse,
321+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
322+
status: 200,
323+
statusText: 'OK',
324+
} as Response);
325+
326+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
327+
const streamResult = await func.stream({});
328+
329+
let errorThrown = false;
330+
try {
331+
for await (const _ of streamResult.stream) {
332+
// This should not execute
333+
}
334+
} catch (error) {
335+
errorThrown = true;
336+
expect((error as FunctionsError).code).to.equal(`${FUNCTIONS_TYPE}/invalid-argument`);
337+
expect((error as FunctionsError).message).to.equal('Invalid input');
338+
}
339+
340+
expect(errorThrown).to.be.true;
341+
expectError(streamResult.data, "invalid-argument", "Invalid input")
342+
343+
mockFetch.restore();
344+
});
345+
346+
it('includes authentication and app check tokens in request headers', async () => {
347+
const authMock: FirebaseAuthInternal = {
348+
getToken: async () => ({ accessToken: 'auth-token' })
349+
} as unknown as FirebaseAuthInternal;
350+
const authProvider = new Provider<FirebaseAuthInternalName>(
351+
'auth-internal',
352+
new ComponentContainer('test')
353+
);
354+
authProvider.setComponent(
355+
new Component('auth-internal', () => authMock, ComponentType.PRIVATE)
356+
);
357+
358+
const functions = createTestService(app, region, authProvider);
359+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
360+
361+
const mockResponse = new ReadableStream({
362+
start(controller) {
363+
controller.enqueue(new TextEncoder().encode('data: {"result":"Success"}\n'));
364+
controller.close();
365+
}
366+
});
367+
368+
mockFetch.resolves({
369+
body: mockResponse,
370+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
371+
status: 200,
372+
statusText: 'OK',
373+
} as Response);
374+
375+
const func = httpsCallable<Record<string, any>, string, string>(functions, 'errTest');
376+
await func.stream({});
377+
378+
expect(mockFetch.calledOnce).to.be.true;
379+
const [_, options] = mockFetch.firstCall.args;
380+
expect(options.headers['Authorization']).to.equal('Bearer auth-token');
381+
expect(options.headers['Content-Type']).to.equal('application/json');
382+
expect(options.headers['Accept']).to.equal('text/event-stream');
383+
384+
mockFetch.restore();
385+
});
386+
});

packages/functions/src/public-types.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,24 @@ export interface HttpsCallableResult<ResponseData = unknown> {
2828
readonly data: ResponseData;
2929
}
3030

31+
/**
32+
* An `HttpsCallableStreamResult` wraps a single streaming result from a function call.
33+
* @public
34+
*/
35+
export interface HttpsCallableStreamResult<ResponseData = unknown, StreamData = unknown> {
36+
readonly data: Promise<ResponseData>;
37+
readonly stream: AsyncIterable<StreamData>;
38+
}
39+
3140
/**
3241
* A reference to a "callable" HTTP trigger in Google Cloud Functions.
3342
* @param data - Data to be passed to callable function.
3443
* @public
3544
*/
36-
export type HttpsCallable<RequestData = unknown, ResponseData = unknown> = (
37-
data?: RequestData | null
38-
) => Promise<HttpsCallableResult<ResponseData>>;
45+
export type HttpsCallable<RequestData = unknown, ResponseData = unknown, StreamData = unknown> = {
46+
(data?: RequestData | null): Promise<HttpsCallableResult<ResponseData>>;
47+
stream: (data?: RequestData | null) => Promise<HttpsCallableStreamResult<ResponseData, StreamData>>;
48+
};
3949

4050
/**
4151
* An interface for metadata about how calls should be executed.

0 commit comments

Comments
 (0)