firebase-functions 6.1.0 → 6.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,6 +85,27 @@ export interface CallableRequest<T = any> {
85
85
  */
86
86
  rawRequest: Request;
87
87
  }
88
+ /**
89
+ * CallableProxyResponse exposes subset of express.Response object
90
+ * to allow writing partial, streaming responses back to the client.
91
+ */
92
+ export interface CallableProxyResponse {
93
+ /**
94
+ * Writes a chunk of the response body to the client. This method can be called
95
+ * multiple times to stream data progressively.
96
+ */
97
+ write: express.Response["write"];
98
+ /**
99
+ * Indicates whether the client has requested and can handle streaming responses.
100
+ * This should be checked before attempting to stream data to avoid compatibility issues.
101
+ */
102
+ acceptsStreaming: boolean;
103
+ /**
104
+ * An AbortSignal that is triggered when the client disconnects or the
105
+ * request is terminated prematurely.
106
+ */
107
+ signal: AbortSignal;
108
+ }
88
109
  /**
89
110
  * The set of Firebase Functions status codes. The codes are the same at the
90
111
  * ones exposed by {@link https://github.com/grpc/grpc/blob/master/doc/statuscodes.md | gRPC}.
@@ -21,7 +21,7 @@
21
21
  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
22
  // SOFTWARE.
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.onCallHandler = exports.checkAuthToken = exports.unsafeDecodeAppCheckToken = exports.unsafeDecodeIdToken = exports.unsafeDecodeToken = exports.decode = exports.encode = exports.isValidRequest = exports.HttpsError = exports.ORIGINAL_AUTH_HEADER = exports.CALLABLE_AUTH_HEADER = void 0;
24
+ exports.onCallHandler = exports.checkAuthToken = exports.unsafeDecodeAppCheckToken = exports.unsafeDecodeIdToken = exports.unsafeDecodeToken = exports.decode = exports.encode = exports.isValidRequest = exports.HttpsError = exports.DEFAULT_HEARTBEAT_SECONDS = exports.ORIGINAL_AUTH_HEADER = exports.CALLABLE_AUTH_HEADER = void 0;
25
25
  const cors = require("cors");
26
26
  const logger = require("../../logger");
27
27
  // TODO(inlined): Decide whether we want to un-version apps or whether we want a
@@ -35,6 +35,8 @@ const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
35
35
  exports.CALLABLE_AUTH_HEADER = "x-callable-context-auth";
36
36
  /** @internal */
37
37
  exports.ORIGINAL_AUTH_HEADER = "x-original-auth";
38
+ /** @internal */
39
+ exports.DEFAULT_HEARTBEAT_SECONDS = 30;
38
40
  /**
39
41
  * Standard error codes and HTTP statuses for different ways a request can fail,
40
42
  * as defined by:
@@ -385,8 +387,8 @@ async function checkAppCheckToken(req, ctx, options) {
385
387
  }
386
388
  }
387
389
  /** @internal */
388
- function onCallHandler(options, handler) {
389
- const wrapped = wrapOnCallHandler(options, handler);
390
+ function onCallHandler(options, handler, version) {
391
+ const wrapped = wrapOnCallHandler(options, handler, version);
390
392
  return (req, res) => {
391
393
  return new Promise((resolve) => {
392
394
  res.on("finish", resolve);
@@ -397,9 +399,36 @@ function onCallHandler(options, handler) {
397
399
  };
398
400
  }
399
401
  exports.onCallHandler = onCallHandler;
402
+ function encodeSSE(data) {
403
+ return `data: ${JSON.stringify(data)}\n`;
404
+ }
400
405
  /** @internal */
401
- function wrapOnCallHandler(options, handler) {
406
+ function wrapOnCallHandler(options, handler, version) {
402
407
  return async (req, res) => {
408
+ const abortController = new AbortController();
409
+ let heartbeatInterval = null;
410
+ const heartbeatSeconds = options.heartbeatSeconds === undefined ? exports.DEFAULT_HEARTBEAT_SECONDS : options.heartbeatSeconds;
411
+ const clearScheduledHeartbeat = () => {
412
+ if (heartbeatInterval) {
413
+ clearTimeout(heartbeatInterval);
414
+ heartbeatInterval = null;
415
+ }
416
+ };
417
+ const scheduleHeartbeat = () => {
418
+ clearScheduledHeartbeat();
419
+ if (!abortController.signal.aborted) {
420
+ heartbeatInterval = setTimeout(() => {
421
+ if (!abortController.signal.aborted) {
422
+ res.write(": ping\n");
423
+ scheduleHeartbeat();
424
+ }
425
+ }, heartbeatSeconds * 1000);
426
+ }
427
+ };
428
+ res.on("close", () => {
429
+ clearScheduledHeartbeat();
430
+ abortController.abort();
431
+ });
403
432
  try {
404
433
  if (!isValidRequest(req)) {
405
434
  logger.error("Invalid request, unable to process.");
@@ -413,7 +442,7 @@ function wrapOnCallHandler(options, handler) {
413
442
  // The original monkey-patched code lived in the functionsEmulatorRuntime
414
443
  // (link: https://github.com/firebase/firebase-tools/blob/accea7abda3cc9fa6bb91368e4895faf95281c60/src/emulator/functionsEmulatorRuntime.ts#L480)
415
444
  // and was not compatible with how monorepos separate out packages (see https://github.com/firebase/firebase-tools/issues/5210).
416
- if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification") && handler.length === 2) {
445
+ if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification") && version === "gcfv1") {
417
446
  const authContext = context.rawRequest.header(exports.CALLABLE_AUTH_HEADER);
418
447
  if (authContext) {
419
448
  logger.debug("Callable functions auth override", {
@@ -452,9 +481,14 @@ function wrapOnCallHandler(options, handler) {
452
481
  // pushes with FCM. In that case, the FCM APIs will validate the token.
453
482
  context.instanceIdToken = req.header("Firebase-Instance-ID-Token");
454
483
  }
484
+ const acceptsStreaming = req.header("accept") === "text/event-stream";
485
+ if (acceptsStreaming && version === "gcfv1") {
486
+ // streaming responses are not supported in v1 callable
487
+ throw new HttpsError("invalid-argument", "Unsupported Accept header 'text/event-stream'");
488
+ }
455
489
  const data = decode(req.body.data);
456
490
  let result;
457
- if (handler.length === 2) {
491
+ if (version === "gcfv1") {
458
492
  result = await handler(data, context);
459
493
  }
460
494
  else {
@@ -462,26 +496,79 @@ function wrapOnCallHandler(options, handler) {
462
496
  ...context,
463
497
  data,
464
498
  };
499
+ const responseProxy = {
500
+ write(chunk) {
501
+ // if client doesn't accept sse-protocol, response.write() is no-op.
502
+ if (!acceptsStreaming) {
503
+ return false;
504
+ }
505
+ // if connection is already closed, response.write() is no-op.
506
+ if (abortController.signal.aborted) {
507
+ return false;
508
+ }
509
+ const formattedData = encodeSSE({ message: chunk });
510
+ const wrote = res.write(formattedData);
511
+ // Reset heartbeat timer after successful write
512
+ if (wrote && heartbeatInterval !== null && heartbeatSeconds > 0) {
513
+ scheduleHeartbeat();
514
+ }
515
+ return wrote;
516
+ },
517
+ acceptsStreaming,
518
+ signal: abortController.signal,
519
+ };
520
+ if (acceptsStreaming) {
521
+ // SSE always responds with 200
522
+ res.status(200);
523
+ if (heartbeatSeconds !== null && heartbeatSeconds > 0) {
524
+ scheduleHeartbeat();
525
+ }
526
+ }
465
527
  // For some reason the type system isn't picking up that the handler
466
528
  // is a one argument function.
467
- result = await handler(arg);
529
+ result = await handler(arg, responseProxy);
530
+ clearScheduledHeartbeat();
531
+ }
532
+ if (!abortController.signal.aborted) {
533
+ // Encode the result as JSON to preserve types like Dates.
534
+ result = encode(result);
535
+ // If there was some result, encode it in the body.
536
+ const responseBody = { result };
537
+ if (acceptsStreaming) {
538
+ res.write(encodeSSE(responseBody));
539
+ res.end();
540
+ }
541
+ else {
542
+ res.status(200).send(responseBody);
543
+ }
544
+ }
545
+ else {
546
+ res.end();
468
547
  }
469
- // Encode the result as JSON to preserve types like Dates.
470
- result = encode(result);
471
- // If there was some result, encode it in the body.
472
- const responseBody = { result };
473
- res.status(200).send(responseBody);
474
548
  }
475
549
  catch (err) {
476
- let httpErr = err;
477
- if (!(err instanceof HttpsError)) {
478
- // This doesn't count as an 'explicit' error.
479
- logger.error("Unhandled error", err);
480
- httpErr = new HttpsError("internal", "INTERNAL");
550
+ if (!abortController.signal.aborted) {
551
+ let httpErr = err;
552
+ if (!(err instanceof HttpsError)) {
553
+ // This doesn't count as an 'explicit' error.
554
+ logger.error("Unhandled error", err);
555
+ httpErr = new HttpsError("internal", "INTERNAL");
556
+ }
557
+ const { status } = httpErr.httpErrorCode;
558
+ const body = { error: httpErr.toJSON() };
559
+ if (version === "gcfv2" && req.header("accept") === "text/event-stream") {
560
+ res.send(encodeSSE(body));
561
+ }
562
+ else {
563
+ res.status(status).send(body);
564
+ }
481
565
  }
482
- const { status } = httpErr.httpErrorCode;
483
- const body = { error: httpErr.toJSON() };
484
- res.status(status).send(body);
566
+ else {
567
+ res.end();
568
+ }
569
+ }
570
+ finally {
571
+ clearScheduledHeartbeat();
485
572
  }
486
573
  };
487
574
  }
@@ -70,9 +70,8 @@ function _onRequestWithOptions(handler, options) {
70
70
  exports._onRequestWithOptions = _onRequestWithOptions;
71
71
  /** @internal */
72
72
  function _onCallWithOptions(handler, options) {
73
- // onCallHandler sniffs the function length of the passed-in callback
74
- // and the user could have only tried to listen to data. Wrap their handler
75
- // in another handler to avoid accidentally triggering the v2 API
73
+ // fix the length of handler to make the call to handler consistent
74
+ // in the onCallHandler
76
75
  const fixedLen = (data, context) => {
77
76
  return (0, onInit_1.withInit)(handler)(data, context);
78
77
  };
@@ -80,7 +79,7 @@ function _onCallWithOptions(handler, options) {
80
79
  enforceAppCheck: options.enforceAppCheck,
81
80
  consumeAppCheckToken: options.consumeAppCheckToken,
82
81
  cors: { origin: true, methods: "POST" },
83
- }, fixedLen));
82
+ }, fixedLen, "gcfv1"));
84
83
  func.__trigger = {
85
84
  labels: {},
86
85
  ...(0, cloud_functions_1.optionsToTrigger)(options),
@@ -1,6 +1,6 @@
1
1
  import * as express from "express";
2
2
  import { ResetValue } from "../../common/options";
3
- import { CallableRequest, FunctionsErrorCode, HttpsError, Request } from "../../common/providers/https";
3
+ import { CallableRequest, CallableProxyResponse, FunctionsErrorCode, HttpsError, Request } from "../../common/providers/https";
4
4
  import { ManifestEndpoint } from "../../runtime/manifest";
5
5
  import { GlobalOptions, SupportedRegion } from "../options";
6
6
  import { Expression } from "../../params";
@@ -132,6 +132,13 @@ export interface CallableOptions extends HttpsOptions {
132
132
  * further decisions, such as requiring additional security checks or rejecting the request.
133
133
  */
134
134
  consumeAppCheckToken?: boolean;
135
+ /**
136
+ * Time in seconds between sending heartbeat messages to keep the connection
137
+ * alive. Set to `null` to disable heartbeats.
138
+ *
139
+ * Defaults to 30 seconds.
140
+ */
141
+ heartbeatSeconds?: number | null;
135
142
  }
136
143
  /**
137
144
  * Handles HTTPS requests.
@@ -175,10 +182,10 @@ export declare function onRequest(handler: (request: Request, response: express.
175
182
  * @param handler - A function that takes a {@link https.CallableRequest}.
176
183
  * @returns A function that you can export and deploy.
177
184
  */
178
- export declare function onCall<T = any, Return = any | Promise<any>>(opts: CallableOptions, handler: (request: CallableRequest<T>) => Return): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
185
+ export declare function onCall<T = any, Return = any | Promise<any>>(opts: CallableOptions, handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
179
186
  /**
180
187
  * Declares a callable method for clients to call using a Firebase SDK.
181
188
  * @param handler - A function that takes a {@link https.CallableRequest}.
182
189
  * @returns A function that you can export and deploy.
183
190
  */
184
- export declare function onCall<T = any, Return = any | Promise<any>>(handler: (request: CallableRequest<T>) => Return): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
191
+ export declare function onCall<T = any, Return = any | Promise<any>>(handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
@@ -130,15 +130,15 @@ function onCall(optsOrHandler, handler) {
130
130
  if (Array.isArray(origin) && origin.length === 1) {
131
131
  origin = origin[0];
132
132
  }
133
- // onCallHandler sniffs the function length to determine which API to present.
134
- // fix the length to prevent api versions from being mismatched.
135
- const fixedLen = (req) => (0, onInit_1.withInit)(handler)(req);
133
+ // fix the length of handler to make the call to handler consistent
134
+ const fixedLen = (req, resp) => (0, onInit_1.withInit)(handler)(req, resp);
136
135
  let func = (0, https_1.onCallHandler)({
137
136
  cors: { origin, methods: "POST" },
138
137
  enforceAppCheck: (_a = opts.enforceAppCheck) !== null && _a !== void 0 ? _a : options.getGlobalOptions().enforceAppCheck,
139
138
  consumeAppCheckToken: opts.consumeAppCheckToken,
140
- }, fixedLen);
141
- func = (0, trace_1.wrapTraceContext)(func);
139
+ heartbeatSeconds: opts.heartbeatSeconds,
140
+ }, fixedLen, "gcfv2");
141
+ func = (0, trace_1.wrapTraceContext)((0, onInit_1.withInit)(func));
142
142
  Object.defineProperty(func, "__trigger", {
143
143
  get: () => {
144
144
  const baseOpts = options.optionsToTriggerAnnotations(options.getGlobalOptions());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firebase-functions",
3
- "version": "6.1.0",
3
+ "version": "6.1.2",
4
4
  "description": "Firebase SDK for Cloud Functions",
5
5
  "keywords": [
6
6
  "firebase",
@@ -287,7 +287,7 @@
287
287
  "@types/nock": "^10.0.3",
288
288
  "@types/node": "^14.18.24",
289
289
  "@types/node-fetch": "^3.0.3",
290
- "@types/sinon": "^7.0.13",
290
+ "@types/sinon": "^9.0.11",
291
291
  "@typescript-eslint/eslint-plugin": "^5.33.1",
292
292
  "@typescript-eslint/parser": "^5.33.1",
293
293
  "api-extractor-model-me": "^0.1.1",
@@ -299,7 +299,7 @@
299
299
  "eslint-config-prettier": "^8.3.0",
300
300
  "eslint-plugin-jsdoc": "^39.2.9",
301
301
  "eslint-plugin-prettier": "^4.0.0",
302
- "firebase-admin": "^12.1.0",
302
+ "firebase-admin": "^13.0.0",
303
303
  "js-yaml": "^3.13.1",
304
304
  "jsdom": "^16.2.1",
305
305
  "jsonwebtoken": "^9.0.0",
@@ -313,13 +313,13 @@
313
313
  "prettier": "^2.7.1",
314
314
  "protobufjs-cli": "^1.1.1",
315
315
  "semver": "^7.3.5",
316
- "sinon": "^7.3.2",
316
+ "sinon": "^9.2.4",
317
317
  "ts-node": "^10.4.0",
318
318
  "typescript": "^4.3.5",
319
319
  "yargs": "^15.3.1"
320
320
  },
321
321
  "peerDependencies": {
322
- "firebase-admin": "^11.10.0 || ^12.0.0"
322
+ "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
323
323
  },
324
324
  "engines": {
325
325
  "node": ">=14.10.0"