voluptasmollitia
Version:
Monorepo for the Firebase JavaScript SDK
281 lines (254 loc) • 8.19 kB
text/typescript
/**
* @license
* Copyright 2017 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.
*/
import { FirebaseApp, _FirebaseService } from '@firebase/app-exp';
import {
HttpsCallable,
HttpsCallableResult,
HttpsCallableOptions
} from './public-types';
import { _errorForResponse, FunctionsError } from './error';
import { ContextProvider } from './context';
import { encode, decode } from './serializer';
import { Provider } from '@firebase/component';
import { FirebaseAuthInternalName } from '@firebase/auth-interop-types';
import { FirebaseMessagingName } from '@firebase/messaging-types';
export const DEFAULT_REGION = 'us-central1';
/**
* The response to an http request.
*/
interface HttpResponse {
status: number;
json: HttpResponseBody | null;
}
/**
* Describes the shape of the HttpResponse body.
* It makes functions that would otherwise take {} able to access the
* possible elements in the body more easily
*/
export interface HttpResponseBody {
data?: unknown;
result?: unknown;
error?: {
message?: unknown;
status?: unknown;
details?: unknown;
};
}
/**
* Returns a Promise that will be rejected after the given duration.
* The error will be of type FunctionsError.
*
* @param millis Number of milliseconds to wait before rejecting.
*/
function failAfter(millis: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new FunctionsError('deadline-exceeded', 'deadline-exceeded'));
}, millis);
});
}
/**
* The main class for the Firebase Functions SDK.
* @internal
*/
export class FunctionsService implements _FirebaseService {
readonly contextProvider: ContextProvider;
emulatorOrigin: string | null = null;
cancelAllRequests: Promise<void>;
deleteService!: () => Promise<void>;
region: string;
customDomain: string | null;
/**
* Creates a new Functions service for the given app.
* @param app - The FirebaseApp to use.
*/
constructor(
readonly app: FirebaseApp,
authProvider: Provider<FirebaseAuthInternalName>,
messagingProvider: Provider<FirebaseMessagingName>,
regionOrCustomDomain: string = DEFAULT_REGION,
readonly fetchImpl: typeof fetch
) {
this.contextProvider = new ContextProvider(authProvider, messagingProvider);
// Cancels all ongoing requests when resolved.
this.cancelAllRequests = new Promise(resolve => {
this.deleteService = () => {
return Promise.resolve(resolve());
};
});
// Resolve the region or custom domain overload by attempting to parse it.
try {
const url = new URL(regionOrCustomDomain);
this.customDomain = url.origin;
this.region = DEFAULT_REGION;
} catch (e) {
this.customDomain = null;
this.region = regionOrCustomDomain;
}
}
_delete(): Promise<void> {
return this.deleteService();
}
/**
* Returns the URL for a callable with the given name.
* @param name - The name of the callable.
* @internal
*/
_url(name: string): string {
const projectId = this.app.options.projectId;
if (this.emulatorOrigin !== null) {
const origin = this.emulatorOrigin;
return `${origin}/${projectId}/${this.region}/${name}`;
}
if (this.customDomain !== null) {
return `${this.customDomain}/${name}`;
}
return `https://${this.region}-${projectId}.cloudfunctions.net/${name}`;
}
}
/**
* Modify this instance to communicate with the Cloud Functions emulator.
*
* Note: this must be called before this instance has been used to do any operations.
*
* @param host The emulator host (ex: localhost)
* @param port The emulator port (ex: 5001)
* @public
*/
export function useFunctionsEmulator(
functionsInstance: FunctionsService,
host: string,
port: number
): void {
functionsInstance.emulatorOrigin = `http://${host}:${port}`;
}
/**
* Returns a reference to the callable https trigger with the given name.
* @param name - The name of the trigger.
* @public
*/
export function httpsCallable<RequestData, ResponseData>(
functionsInstance: FunctionsService,
name: string,
options?: HttpsCallableOptions
): HttpsCallable<RequestData, ResponseData> {
return (data => {
return call(functionsInstance, name, data, options || {});
}) as HttpsCallable<RequestData, ResponseData>;
}
/**
* Does an HTTP POST and returns the completed response.
* @param url The url to post to.
* @param body The JSON body of the post.
* @param headers The HTTP headers to include in the request.
* @return A Promise that will succeed when the request finishes.
*/
async function postJSON(
url: string,
body: unknown,
headers: { [key: string]: string },
fetchImpl: typeof fetch
): Promise<HttpResponse> {
headers['Content-Type'] = 'application/json';
let response: Response;
try {
response = await fetchImpl(url, {
method: 'POST',
body: JSON.stringify(body),
headers
});
} catch (e) {
// This could be an unhandled error on the backend, or it could be a
// network error. There's no way to know, since an unhandled error on the
// backend will fail to set the proper CORS header, and thus will be
// treated as a network error by fetch.
return {
status: 0,
json: null
};
}
let json: HttpResponseBody | null = null;
try {
json = await response.json();
} catch (e) {
// If we fail to parse JSON, it will fail the same as an empty body.
}
return {
status: response.status,
json
};
}
/**
* Calls a callable function asynchronously and returns the result.
* @param name The name of the callable trigger.
* @param data The data to pass as params to the function.s
*/
async function call(
functionsInstance: FunctionsService,
name: string,
data: unknown,
options: HttpsCallableOptions
): Promise<HttpsCallableResult> {
const url = functionsInstance._url(name);
// Encode any special types, such as dates, in the input data.
data = encode(data);
const body = { data };
// Add a header for the authToken.
const headers: { [key: string]: string } = {};
const context = await functionsInstance.contextProvider.getContext();
if (context.authToken) {
headers['Authorization'] = 'Bearer ' + context.authToken;
}
if (context.messagingToken) {
headers['Firebase-Instance-ID-Token'] = context.messagingToken;
}
// Default timeout to 70s, but let the options override it.
const timeout = options.timeout || 70000;
const response = await Promise.race([
postJSON(url, body, headers, functionsInstance.fetchImpl),
failAfter(timeout),
functionsInstance.cancelAllRequests
]);
// If service was deleted, interrupted response throws an error.
if (!response) {
throw new FunctionsError(
'cancelled',
'Firebase Functions instance was deleted.'
);
}
// Check for an error status, regardless of http status.
const error = _errorForResponse(response.status, response.json);
if (error) {
throw error;
}
if (!response.json) {
throw new FunctionsError('internal', 'Response is not valid JSON object.');
}
let responseData = response.json.data;
// TODO(klimt): For right now, allow "result" instead of "data", for
// backwards compatibility.
if (typeof responseData === 'undefined') {
responseData = response.json.result;
}
if (typeof responseData === 'undefined') {
// Consider the response malformed.
throw new FunctionsError('internal', 'Response is missing data field.');
}
// Decode any special types, such as dates, in the returned data.
const decodedData = decode(responseData);
return { data: decodedData };
}