firebase-functions 6.1.1 → 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.
@@ -90,8 +90,21 @@ export interface CallableRequest<T = any> {
90
90
  * to allow writing partial, streaming responses back to the client.
91
91
  */
92
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
+ */
93
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
+ */
94
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;
95
108
  }
96
109
  /**
97
110
  * The set of Firebase Functions status codes. The codes are the same at the
@@ -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:
@@ -403,6 +405,30 @@ function encodeSSE(data) {
403
405
  /** @internal */
404
406
  function wrapOnCallHandler(options, handler, version) {
405
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
+ });
406
432
  try {
407
433
  if (!isValidRequest(req)) {
408
434
  logger.error("Invalid request, unable to process.");
@@ -470,52 +496,79 @@ function wrapOnCallHandler(options, handler, version) {
470
496
  ...context,
471
497
  data,
472
498
  };
473
- // TODO: set up optional heartbeat
474
499
  const responseProxy = {
475
500
  write(chunk) {
476
- if (acceptsStreaming) {
477
- const formattedData = encodeSSE({ message: chunk });
478
- return res.write(formattedData);
479
- }
480
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;
481
516
  },
482
517
  acceptsStreaming,
518
+ signal: abortController.signal,
483
519
  };
484
520
  if (acceptsStreaming) {
485
521
  // SSE always responds with 200
486
522
  res.status(200);
523
+ if (heartbeatSeconds !== null && heartbeatSeconds > 0) {
524
+ scheduleHeartbeat();
525
+ }
487
526
  }
488
527
  // For some reason the type system isn't picking up that the handler
489
528
  // is a one argument function.
490
529
  result = await handler(arg, responseProxy);
530
+ clearScheduledHeartbeat();
491
531
  }
492
- // Encode the result as JSON to preserve types like Dates.
493
- result = encode(result);
494
- // If there was some result, encode it in the body.
495
- const responseBody = { result };
496
- if (acceptsStreaming) {
497
- res.write(encodeSSE(responseBody));
498
- res.end();
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
+ }
499
544
  }
500
545
  else {
501
- res.status(200).send(responseBody);
546
+ res.end();
502
547
  }
503
548
  }
504
549
  catch (err) {
505
- let httpErr = err;
506
- if (!(err instanceof HttpsError)) {
507
- // This doesn't count as an 'explicit' error.
508
- logger.error("Unhandled error", err);
509
- httpErr = new HttpsError("internal", "INTERNAL");
510
- }
511
- const { status } = httpErr.httpErrorCode;
512
- const body = { error: httpErr.toJSON() };
513
- if (version === "gcfv2" && req.header("accept") === "text/event-stream") {
514
- res.send(encodeSSE(body));
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
+ }
515
565
  }
516
566
  else {
517
- res.status(status).send(body);
567
+ res.end();
518
568
  }
519
569
  }
570
+ finally {
571
+ clearScheduledHeartbeat();
572
+ }
520
573
  };
521
574
  }
@@ -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.
@@ -136,6 +136,7 @@ function onCall(optsOrHandler, handler) {
136
136
  cors: { origin, methods: "POST" },
137
137
  enforceAppCheck: (_a = opts.enforceAppCheck) !== null && _a !== void 0 ? _a : options.getGlobalOptions().enforceAppCheck,
138
138
  consumeAppCheckToken: opts.consumeAppCheckToken,
139
+ heartbeatSeconds: opts.heartbeatSeconds,
139
140
  }, fixedLen, "gcfv2");
140
141
  func = (0, trace_1.wrapTraceContext)((0, onInit_1.withInit)(func));
141
142
  Object.defineProperty(func, "__trigger", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firebase-functions",
3
- "version": "6.1.1",
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",
@@ -313,7 +313,7 @@
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"