Skip to content

Commit 5501791

Browse files
authored
Fix Firebase Functions Emulator usage on Firebase Studio (#9204)
* Used valid path for pingserver * Added test * Create three-balloons-collect.md * Fixed fromUrl * Removed only * Fixed typings
1 parent cc605e7 commit 5501791

File tree

3 files changed

+161
-6
lines changed

3 files changed

+161
-6
lines changed

.changeset/three-balloons-collect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@firebase/functions": patch
3+
---
4+
5+
Fixed issue where Firebase Functions SDK caused CORS errors when connected to emulators in Firebase Studio

packages/functions/src/callable.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ import {
3737
AppCheckInternalComponentName
3838
} from '@firebase/app-check-interop-types';
3939
import { makeFakeApp, createTestService } from '../test/utils';
40-
import { FunctionsService, httpsCallable } from './service';
40+
import {
41+
FunctionsService,
42+
httpsCallable,
43+
httpsCallableFromURL
44+
} from './service';
4145
import { FUNCTIONS_TYPE } from './constants';
4246
import { FunctionsError } from './error';
4347

@@ -523,9 +527,136 @@ describe('Firebase Functions > Stream', () => {
523527
const [_, options] = mockFetch.firstCall.args;
524528
expect(options.headers['Authorization']).to.equal('Bearer auth-token');
525529
expect(options.headers['Content-Type']).to.equal('application/json');
530+
expect(options.credentials).to.equal(undefined);
526531
expect(options.headers['Accept']).to.equal('text/event-stream');
527532
});
528533

534+
it('calls cloud workstations with credentials', async () => {
535+
const authMock: FirebaseAuthInternal = {
536+
getToken: async () => ({ accessToken: 'auth-token' })
537+
} as unknown as FirebaseAuthInternal;
538+
const authProvider = new Provider<FirebaseAuthInternalName>(
539+
'auth-internal',
540+
new ComponentContainer('test')
541+
);
542+
authProvider.setComponent(
543+
new Component('auth-internal', () => authMock, ComponentType.PRIVATE)
544+
);
545+
const appCheckMock: FirebaseAppCheckInternal = {
546+
getToken: async () => ({ token: 'app-check-token' })
547+
} as unknown as FirebaseAppCheckInternal;
548+
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
549+
'app-check-internal',
550+
new ComponentContainer('test')
551+
);
552+
appCheckProvider.setComponent(
553+
new Component(
554+
'app-check-internal',
555+
() => appCheckMock,
556+
ComponentType.PRIVATE
557+
)
558+
);
559+
560+
const functions = createTestService(
561+
app,
562+
region,
563+
authProvider,
564+
undefined,
565+
appCheckProvider
566+
);
567+
functions.emulatorOrigin = 'test.cloudworkstations.dev';
568+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
569+
570+
const mockResponse = new ReadableStream({
571+
start(controller) {
572+
controller.enqueue(
573+
new TextEncoder().encode('data: {"result":"Success"}\n')
574+
);
575+
controller.close();
576+
}
577+
});
578+
579+
mockFetch.resolves({
580+
body: mockResponse,
581+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
582+
status: 200,
583+
statusText: 'OK'
584+
} as Response);
585+
586+
const func = httpsCallable<Record<string, any>, string, string>(
587+
functions,
588+
'stream'
589+
);
590+
await func.stream({});
591+
592+
expect(mockFetch.calledOnce).to.be.true;
593+
const [_, options] = mockFetch.firstCall.args;
594+
expect(options.credentials).to.equal('include');
595+
});
596+
597+
it('calls streamFromURL cloud workstations with credentials', async () => {
598+
const authMock: FirebaseAuthInternal = {
599+
getToken: async () => ({ accessToken: 'auth-token' })
600+
} as unknown as FirebaseAuthInternal;
601+
const authProvider = new Provider<FirebaseAuthInternalName>(
602+
'auth-internal',
603+
new ComponentContainer('test')
604+
);
605+
authProvider.setComponent(
606+
new Component('auth-internal', () => authMock, ComponentType.PRIVATE)
607+
);
608+
const appCheckMock: FirebaseAppCheckInternal = {
609+
getToken: async () => ({ token: 'app-check-token' })
610+
} as unknown as FirebaseAppCheckInternal;
611+
const appCheckProvider = new Provider<AppCheckInternalComponentName>(
612+
'app-check-internal',
613+
new ComponentContainer('test')
614+
);
615+
appCheckProvider.setComponent(
616+
new Component(
617+
'app-check-internal',
618+
() => appCheckMock,
619+
ComponentType.PRIVATE
620+
)
621+
);
622+
623+
const functions = createTestService(
624+
app,
625+
region,
626+
authProvider,
627+
undefined,
628+
appCheckProvider
629+
);
630+
functions.emulatorOrigin = 'test.cloudworkstations.dev';
631+
const mockFetch = sinon.stub(functions, 'fetchImpl' as any);
632+
633+
const mockResponse = new ReadableStream({
634+
start(controller) {
635+
controller.enqueue(
636+
new TextEncoder().encode('data: {"result":"Success"}\n')
637+
);
638+
controller.close();
639+
}
640+
});
641+
642+
mockFetch.resolves({
643+
body: mockResponse,
644+
headers: new Headers({ 'Content-Type': 'text/event-stream' }),
645+
status: 200,
646+
statusText: 'OK'
647+
} as Response);
648+
649+
const func = httpsCallableFromURL<Record<string, any>, string, string>(
650+
functions,
651+
'stream'
652+
);
653+
await func.stream({});
654+
655+
expect(mockFetch.calledOnce).to.be.true;
656+
const [_, options] = mockFetch.firstCall.args;
657+
expect(options.credentials).to.equal('include');
658+
});
659+
529660
it('aborts during initial fetch', async () => {
530661
const controller = new AbortController();
531662

packages/functions/src/service.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export function connectFunctionsEmulator(
185185
}://${host}:${port}`;
186186
// Workaround to get cookies in Firebase Studio
187187
if (useSsl) {
188-
void pingServer(functionsInstance.emulatorOrigin);
188+
void pingServer(functionsInstance.emulatorOrigin + '/backends');
189189
updateEmulatorBanner('Functions', true);
190190
}
191191
}
@@ -245,18 +245,29 @@ export function httpsCallableFromURL<
245245
return callable as HttpsCallable<RequestData, ResponseData, StreamData>;
246246
}
247247

248+
function getCredentials(
249+
functionsInstance: FunctionsService
250+
): 'include' | undefined {
251+
return functionsInstance.emulatorOrigin &&
252+
isCloudWorkstation(functionsInstance.emulatorOrigin)
253+
? 'include'
254+
: undefined;
255+
}
256+
248257
/**
249258
* Does an HTTP POST and returns the completed response.
250259
* @param url The url to post to.
251260
* @param body The JSON body of the post.
252261
* @param headers The HTTP headers to include in the request.
262+
* @param functionsInstance functions instance that is calling postJSON
253263
* @return A Promise that will succeed when the request finishes.
254264
*/
255265
async function postJSON(
256266
url: string,
257267
body: unknown,
258268
headers: { [key: string]: string },
259-
fetchImpl: typeof fetch
269+
fetchImpl: typeof fetch,
270+
functionsInstance: FunctionsService
260271
): Promise<HttpResponse> {
261272
headers['Content-Type'] = 'application/json';
262273

@@ -265,7 +276,8 @@ async function postJSON(
265276
response = await fetchImpl(url, {
266277
method: 'POST',
267278
body: JSON.stringify(body),
268-
headers
279+
headers,
280+
credentials: getCredentials(functionsInstance)
269281
});
270282
} catch (e) {
271283
// This could be an unhandled error on the backend, or it could be a
@@ -353,7 +365,13 @@ async function callAtURL(
353365

354366
const failAfterHandle = failAfter(timeout);
355367
const response = await Promise.race([
356-
postJSON(url, body, headers, functionsInstance.fetchImpl),
368+
postJSON(
369+
url,
370+
body,
371+
headers,
372+
functionsInstance.fetchImpl,
373+
functionsInstance
374+
),
357375
failAfterHandle.promise,
358376
functionsInstance.cancelAllRequests
359377
]);
@@ -439,7 +457,8 @@ async function streamAtURL(
439457
method: 'POST',
440458
body: JSON.stringify(body),
441459
headers,
442-
signal: options?.signal
460+
signal: options?.signal,
461+
credentials: getCredentials(functionsInstance)
443462
});
444463
} catch (e) {
445464
if (e instanceof Error && e.name === 'AbortError') {

0 commit comments

Comments
 (0)