From 3f12b70ca4ba35ae5eacdf84b97adc046c01b764 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Tue, 23 Jun 2026 15:54:46 -0700 Subject: [PATCH 1/7] Security audit: update send-email action dependencies --- .github/actions/send-email/package-lock.json | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/send-email/package-lock.json b/.github/actions/send-email/package-lock.json index cf74724200..3e9cd1acea 100644 --- a/.github/actions/send-email/package-lock.json +++ b/.github/actions/send-email/package-lock.json @@ -228,16 +228,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -329,9 +329,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -422,9 +422,9 @@ } }, "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" From 8387aad44129fb4fbc6796dcad65926a08e6d084 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Tue, 23 Jun 2026 16:02:24 -0700 Subject: [PATCH 2/7] Security audit: rebuild send-email action dist --- .github/actions/send-email/dist/index.js | 266 ++++++++++++++++++----- 1 file changed, 207 insertions(+), 59 deletions(-) diff --git a/.github/actions/send-email/dist/index.js b/.github/actions/send-email/dist/index.js index bd03312a05..46173b19b1 100644 --- a/.github/actions/send-email/dist/index.js +++ b/.github/actions/send-email/dist/index.js @@ -1068,6 +1068,18 @@ var setToStringTag = __nccwpck_require__(8700); var hasOwn = __nccwpck_require__(4076); var populate = __nccwpck_require__(1835); +/** + * Escape CR, LF, and `"` in a multipart `name`/`filename` parameter, so a field + * name or filename can not break out of its header line to inject headers or + * smuggle additional parts. Matches the WHATWG HTML multipart/form-data encoding. + * + * @param {string} str - the parameter value to escape + * @returns {string} the escaped value + */ +function escapeHeaderParam(str) { + return String(str).replace(/\r/g, '%0D').replace(/\n/g, '%0A').replace(/"/g, '%22'); +} + /** * Create readable "multipart/form-data" streams. * Can be used to submit forms @@ -1233,7 +1245,7 @@ FormData.prototype._multiPartHeader = function (field, value, options) { var contents = ''; var headers = { // add custom disposition as third element or keep it two elements if not - 'Content-Disposition': ['form-data', 'name="' + field + '"'].concat(contentDisposition || []), + 'Content-Disposition': ['form-data', 'name="' + escapeHeaderParam(field) + '"'].concat(contentDisposition || []), // if no content type. allow it to be empty array 'Content-Type': [].concat(contentType || []) }; @@ -1287,7 +1299,7 @@ FormData.prototype._getContentDisposition = function (value, options) { // eslin } if (filename) { - return 'filename="' + filename + '"'; + return 'filename="' + escapeHeaderParam(filename) + '"'; } }; @@ -6974,7 +6986,6 @@ function defaultFactory (origin, opts) { class Agent extends DispatcherBase { constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -7362,6 +7373,9 @@ const EMPTY_BUF = Buffer.alloc(0) const FastBuffer = Buffer[Symbol.species] const addListener = util.addListener const removeAllListeners = util.removeAllListeners +const kIdleSocketValidation = Symbol('kIdleSocketValidation') +const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout') +const kSocketUsed = Symbol('kSocketUsed') let extractBody @@ -7584,29 +7598,71 @@ class Parser { const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr - if (ret === constants.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)) - } else if (ret === constants.ERROR.PAUSED) { - this.paused = true - socket.unshift(data.slice(offset)) - } else if (ret !== constants.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr) - let message = '' - /* istanbul ignore else: difficult to make a test case for */ - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) - message = - 'Response does not match the HTTP/1.1 protocol (' + - Buffer.from(llhttp.memory.buffer, ptr, len).toString() + - ')' - } - throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset)) + if (ret !== constants.ERROR.OK) { + const body = data.subarray(offset) + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body) + } else if (ret === constants.ERROR.PAUSED) { + this.paused = true + socket.unshift(body) + } else { + throw this.createError(ret, body) + } } } catch (err) { util.destroy(socket, err) } } + finish () { + assert(currentParser === null) + assert(this.ptr != null) + assert(!this.paused) + + const { llhttp } = this + + let ret + + try { + currentParser = this + ret = llhttp.llhttp_finish(this.ptr) + } finally { + currentParser = null + } + + if (ret === constants.ERROR.OK) { + return null + } + + if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) { + this.paused = true + return null + } + + return this.createError(ret, EMPTY_BUF) + } + + createError (ret, data) { + const { llhttp, contentLength, bytesRead } = this + + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError() + } + + const ptr = llhttp.llhttp_get_error_reason(this.ptr) + let message = '' + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) + message = + 'Response does not match the HTTP/1.1 protocol (' + + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + + ')' + } + + return new HTTPParserError(message, constants.ERROR[ret], data) + } + destroy () { assert(this.ptr != null) assert(currentParser == null) @@ -7634,6 +7690,11 @@ class Parser { return -1 } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + const request = client[kQueue][client[kRunningIdx]] if (!request) { return -1 @@ -7737,6 +7798,11 @@ class Parser { return -1 } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + const request = client[kQueue][client[kRunningIdx]] /* istanbul ignore next: difficult to make a test case for */ @@ -7910,6 +7976,7 @@ class Parser { request.onComplete(headers) client[kQueue][client[kRunningIdx]++] = null + socket[kSocketUsed] = true if (socket[kWriting]) { assert(client[kRunning] === 0) @@ -7968,6 +8035,9 @@ async function connectH1 (client, socket) { socket[kWriting] = false socket[kReset] = false socket[kBlocking] = false + socket[kIdleSocketValidation] = 0 + socket[kIdleSocketValidationTimeout] = null + socket[kSocketUsed] = false socket[kParser] = new Parser(client, socket, llhttpInstance) addListener(socket, 'error', function (err) { @@ -7978,8 +8048,11 @@ async function connectH1 (client, socket) { // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded // to the user. if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so for as a valid response. - parser.onMessageComplete() + const parserErr = parser.finish() + if (parserErr) { + this[kError] = parserErr + this[kClient][kOnError](parserErr) + } return } @@ -7998,8 +8071,10 @@ async function connectH1 (client, socket) { const parser = this[kParser] if (parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so far as a valid response. - parser.onMessageComplete() + const parserErr = parser.finish() + if (parserErr) { + util.destroy(this, parserErr) + } return } @@ -8009,10 +8084,11 @@ async function connectH1 (client, socket) { const client = this[kClient] const parser = this[kParser] + clearIdleSocketValidation(this) + if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so far as a valid response. - parser.onMessageComplete() + this[kError] = parser.finish() || this[kError] } this[kParser].destroy() @@ -8075,7 +8151,7 @@ async function connectH1 (client, socket) { return socket.destroyed }, busy (request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true } @@ -8113,6 +8189,31 @@ async function connectH1 (client, socket) { } } +function clearIdleSocketValidation (socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearTimeout(socket[kIdleSocketValidationTimeout]) + socket[kIdleSocketValidationTimeout] = null + } + + socket[kIdleSocketValidation] = 0 +} + +function scheduleIdleSocketValidation (client, socket) { + socket[kIdleSocketValidation] = 1 + socket[kIdleSocketValidationTimeout] = setTimeout(() => { + socket[kIdleSocketValidationTimeout] = null + socket[kIdleSocketValidation] = 2 + + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume]() + } + }, 0) + socket[kIdleSocketValidationTimeout].unref?.() +} + +/** + * @param {import('./client.js')} client + */ function resumeH1 (client) { const socket = client[kSocket] @@ -8127,6 +8228,32 @@ function resumeH1 (client) { socket[kNoRef] = false } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket) + socket[kParser].readMore() + if (socket.destroyed) { + return + } + return + } + + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore() + if (socket.destroyed) { + return + } + return + } + } + + if (client[kRunning] === 0) { + socket[kParser].readMore() + if (socket.destroyed) { + return + } + } + if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE) @@ -8220,6 +8347,7 @@ function writeH1 (client, request) { } const socket = client[kSocket] + clearIdleSocketValidation(socket) const abort = (err) => { if (request.aborted || request.completed) { @@ -10092,6 +10220,7 @@ class DispatcherBase extends Dispatcher { get webSocketOptions () { return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 } } @@ -16028,32 +16157,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) // If the attribute-name case-insensitively matches the string // "SameSite", the user agent MUST process the cookie-av as follows: - // 1. Let enforcement be "Default". - let enforcement = 'Default' - const attributeValueLowercase = attributeValue.toLowerCase() - // 2. If cookie-av's attribute-value is a case-insensitive match for - // "None", set enforcement to "None". - if (attributeValueLowercase.includes('none')) { - enforcement = 'None' - } - // 3. If cookie-av's attribute-value is a case-insensitive match for - // "Strict", set enforcement to "Strict". - if (attributeValueLowercase.includes('strict')) { - enforcement = 'Strict' + // 1. If cookie-av's attribute-value is a case-insensitive match for + // "None", append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of "None". + if (attributeValueLowercase === 'none') { + cookieAttributeList.sameSite = 'None' + } else if (attributeValueLowercase === 'strict') { + // 2. If cookie-av's attribute-value is a case-insensitive match for + // "Strict", append an attribute to the cookie-attribute-list with + // an attribute-name of "SameSite" and an attribute-value of + // "Strict". + cookieAttributeList.sameSite = 'Strict' + } else if (attributeValueLowercase === 'lax') { + // 3. If cookie-av's attribute-value is a case-insensitive match for + // "Lax", append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of "Lax". + cookieAttributeList.sameSite = 'Lax' } - - // 4. If cookie-av's attribute-value is a case-insensitive match for - // "Lax", set enforcement to "Lax". - if (attributeValueLowercase.includes('lax')) { - enforcement = 'Lax' - } - - // 5. Append an attribute to the cookie-attribute-list with an - // attribute-name of "SameSite" and an attribute-value of - // enforcement. - cookieAttributeList.sameSite = enforcement } else { cookieAttributeList.unparsed ??= [] @@ -28879,6 +29001,11 @@ const { closeWebSocketConnection } = __nccwpck_require__(6897) const { PerMessageDeflate } = __nccwpck_require__(9469) const { MessageSizeExceededError } = __nccwpck_require__(8707) +function failWebsocketConnectionWithCode (ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)) + failWebsocketConnection(ws, reason) +} + // This code was influenced by ws released under the MIT license. // Copyright (c) 2011 Einar Otto Stangvik // Copyright (c) 2013 Arnout Kazemier and contributors @@ -28898,19 +29025,23 @@ class ByteParser extends Writable { /** @type {Map} */ #extensions + /** @type {number} */ + #maxFragments + /** @type {number} */ #maxPayloadSize /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxPayloadSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor (ws, extensions, options = {}) { super() this.ws = ws this.#extensions = extensions == null ? new Map() : extensions + this.#maxFragments = options.maxFragments ?? 0 this.#maxPayloadSize = options.maxPayloadSize ?? 0 if (this.#extensions.has('permessage-deflate')) { @@ -28934,9 +29065,9 @@ class ByteParser extends Writable { if ( this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && - this.#info.payloadLength > this.#maxPayloadSize + this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize ) { - failWebsocketConnection(this.ws, 'Payload size exceeds maximum allowed size') + failWebsocketConnectionWithCode(this.ws, 1009, 'Payload size exceeds maximum allowed size') return false } @@ -29101,10 +29232,12 @@ class ByteParser extends Writable { this.#state = parserStates.INFO } else { if (!this.#info.compressed) { - this.writeFragments(body) + if (!this.writeFragments(body)) { + return + } if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { - failWebsocketConnection(this.ws, new MessageSizeExceededError().message) + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message) return } @@ -29123,14 +29256,17 @@ class ByteParser extends Writable { this.#info.fin, (error, data) => { if (error) { - failWebsocketConnection(this.ws, error.message) + const code = error instanceof MessageSizeExceededError ? 1009 : 1007 + failWebsocketConnectionWithCode(this.ws, code, error.message) return } - this.writeFragments(data) + if (!this.writeFragments(data)) { + return + } if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { - failWebsocketConnection(this.ws, new MessageSizeExceededError().message) + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message) return } @@ -29200,8 +29336,17 @@ class ByteParser extends Writable { } writeFragments (fragment) { + if ( + this.#maxFragments > 0 && + this.#fragments.length === this.#maxFragments + ) { + failWebsocketConnectionWithCode(this.ws, 1008, 'Too many message fragments') + return false + } + this.#fragmentsBytes += fragment.length this.#fragments.push(fragment) + return true } consumeFragments () { @@ -30254,9 +30399,12 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this[kResponse] = response - const maxPayloadSize = this[kController]?.dispatcher?.webSocketOptions?.maxPayloadSize + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions + const maxFragments = webSocketOptions?.maxFragments + const maxPayloadSize = webSocketOptions?.maxPayloadSize const parser = new ByteParser(this, parsedExtensions, { + maxFragments, maxPayloadSize }) parser.on('drain', onParserDrain) From e7f717bef5fa0a24be54c9855e7a2f62775d8976 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 9 Jul 2026 12:50:34 -0700 Subject: [PATCH 3/7] feat(functions): Implement TaskQueue Scopes --- etc/firebase-admin.functions.api.md | 14 +- .../functions-api-client-internal.ts | 112 +++++++-- src/functions/functions-api.ts | 13 + src/functions/functions.ts | 225 +++++++++++++++-- src/functions/index.ts | 1 + .../functions-api-client-internal.spec.ts | 235 ++++++++++++++++-- test/unit/functions/functions.spec.ts | 54 ++++ 7 files changed, 598 insertions(+), 56 deletions(-) diff --git a/etc/firebase-admin.functions.api.md b/etc/firebase-admin.functions.api.md index 9ddb0a7b27..eb6703160b 100644 --- a/etc/firebase-admin.functions.api.md +++ b/etc/firebase-admin.functions.api.md @@ -37,9 +37,21 @@ export class Functions { // // (undocumented) readonly app: App; - taskQueue>(functionName: string, extensionId?: string): TaskQueue; + taskQueue>(functionName: string, scope?: FunctionScope): TaskQueue; + // @deprecated + taskQueue>(functionName: string, deprecatedExtensionId: string): TaskQueue; } +// @public +export type FunctionScope = { + scope: 'current'; +} | { + scope: 'global'; +} | { + scope: 'extension'; + instance: string; +}; + // @public export const FunctionsErrorCode: { readonly ABORTED: "aborted"; diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 61b2ae5bf8..5a60771582 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -24,9 +24,17 @@ import { FirebaseError, toHttpResponse } from '../utils/error'; import { FirebaseFunctionsError, FunctionsErrorCode, FUNCTIONS_ERROR_CODE_MAPPING } from './error'; import * as utils from '../utils/index'; import * as validator from '../utils/validator'; -import { TaskOptions } from './functions-api'; +import { TaskOptions, FunctionScope } from './functions-api'; import { ApplicationDefaultCredential } from '../app/credential-internal'; +export type InternalFunctionScope = FunctionScope | { + scope: 'kit'; + instance: string; +} | { + scope: 'extensionOrKit'; + instance: string; +}; + const CLOUD_TASKS_API_RESOURCE_PATH = 'projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks'; const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/' + CLOUD_TASKS_API_RESOURCE_PATH; const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}'; @@ -66,9 +74,13 @@ export class FunctionsApiClient { * * @param id - The ID of the task to delete. * @param functionName - The function name of the queue. - * @param extensionId - Optional canonical ID of the extension. + * @param scope - Optional FunctionScope configuration. */ - public async delete(id: string, functionName: string, extensionId?: string): Promise { + public async delete( + id: string, + functionName: string, + scope?: InternalFunctionScope + ): Promise { if (!validator.isNonEmptyString(functionName)) { throw new FirebaseFunctionsError({ code: 'invalid-argument', @@ -101,13 +113,13 @@ export class FunctionsApiClient { message: 'No valid function name specified to enqueue tasks for.' }); } - if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) { - resources.resourceId = `ext-${extensionId}-${resources.resourceId}`; - } + + const { resourceId } = this.resolveResourceId(resources.resourceId, scope); + const targetResources = { ...resources, resourceId }; try { - const serviceUrl = tasksEmulatorUrl(resources, this.emulatorHost)?.concat('/', id) - ?? await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id)); + const serviceUrl = tasksEmulatorUrl(targetResources, this.emulatorHost)?.concat('/', id) + ?? await this.getUrl(targetResources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id)); const request: HttpRequestConfig = { method: 'DELETE', url: serviceUrl, @@ -116,10 +128,6 @@ export class FunctionsApiClient { await this.httpClient.send(request); } catch (err: unknown) { if (err instanceof RequestResponseError) { - if (err.response.status === 404) { - // if no task with the provided ID exists, then ignore the delete. - return; - } throw this.toFirebaseError(err); } else { throw err; @@ -132,10 +140,15 @@ export class FunctionsApiClient { * * @param data - The data payload of the task. * @param functionName - The functionName of the queue. - * @param extensionId - Optional canonical ID of the extension. + * @param scope - Optional FunctionScope configuration. * @param opts - Optional options when enqueuing a new task. */ - public async enqueue(data: any, functionName: string, extensionId?: string, opts?: TaskOptions): Promise { + public async enqueue( + data: any, + functionName: string, + scope?: InternalFunctionScope, + opts?: TaskOptions + ): Promise { if (!validator.isNonEmptyString(functionName)) { throw new FirebaseFunctionsError({ code: 'invalid-argument', @@ -161,17 +174,17 @@ export class FunctionsApiClient { message: 'No valid function name specified to enqueue tasks for.' }); } - if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) { - resources.resourceId = `ext-${extensionId}-${resources.resourceId}`; - } - const task = this.validateTaskOptions(data, resources, opts); + const { resourceId, extensionOrKitId } = this.resolveResourceId(resources.resourceId, scope); + const targetResources = { ...resources, resourceId }; + try { + const task = this.validateTaskOptions(data, targetResources, opts); const serviceUrl = - tasksEmulatorUrl(resources, this.emulatorHost) ?? - await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT); + tasksEmulatorUrl(targetResources, this.emulatorHost) ?? + await this.getUrl(targetResources, CLOUD_TASKS_API_URL_FORMAT); - const taskPayload = await this.updateTaskPayload(task, resources, extensionId); + const taskPayload = await this.updateTaskPayload(task, targetResources, extensionOrKitId); const request: HttpRequestConfig = { method: 'POST', url: serviceUrl, @@ -199,6 +212,55 @@ export class FunctionsApiClient { } } + private resolveResourceId( + resourceId: string, + scope?: InternalFunctionScope + ): { resourceId: string; extensionOrKitId?: string } { + if (typeof scope === 'undefined') { + return this.resolveResourceId(resourceId, { scope: 'current' }); + } + + const scopeObj = scope as any; + switch (scopeObj.scope) { + case 'current': { + const extInstanceId = process.env.EXT_INSTANCE_ID; + if (validator.isNonEmptyString(extInstanceId)) { + return { + resourceId: `ext-${extInstanceId}-${resourceId}`, + extensionOrKitId: extInstanceId, + }; + } + const kitInstanceId = process.env.KIT_INSTANCE_ID; + if (validator.isNonEmptyString(kitInstanceId)) { + return { + resourceId: `kit-${kitInstanceId}-${resourceId}`, + extensionOrKitId: kitInstanceId, + }; + } + return { resourceId }; + } + case 'global': + return { resourceId }; + case 'extension': + return { + resourceId: `ext-${scopeObj.instance}-${resourceId}`, + extensionOrKitId: scopeObj.instance, + }; + case 'kit': // kit scope is secretly accepted for forward compatibility + return { + resourceId: `kit-${scopeObj.instance}-${resourceId}`, + extensionOrKitId: scopeObj.instance, + }; + case 'extensionOrKit': + return { + resourceId: `ext-${scopeObj.instance}-${resourceId}`, + extensionOrKitId: scopeObj.instance, + }; + default: + return { resourceId }; + } + } + private getUrl(resourceName: utils.ParsedResource, urlFormat: string): Promise { let { locationId } = resourceName; const { projectId, resourceId } = resourceName; @@ -349,7 +411,11 @@ export class FunctionsApiClient { return task; } - private async updateTaskPayload(task: Task, resources: utils.ParsedResource, extensionId?: string): Promise { + private async updateTaskPayload( + task: Task, + resources: utils.ParsedResource, + extensionOrKitId?: string + ): Promise { const defaultUrl = this.emulatorHost ? '' : await this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT); @@ -360,7 +426,7 @@ export class FunctionsApiClient { task.httpRequest.url = functionUrl; // When run from a deployed extension, we should be using ComputeEngineCredentials - if (validator.isNonEmptyString(extensionId) && this.app.options.credential + if (validator.isNonEmptyString(extensionOrKitId) && this.app.options.credential instanceof ApplicationDefaultCredential && await this.app.options.credential.isComputeEngineCredential()) { const idToken = await this.app.options.credential.getIDToken(functionUrl); task.httpRequest.headers = { ...task.httpRequest.headers, 'Authorization': `Bearer ${idToken}` }; diff --git a/src/functions/functions-api.ts b/src/functions/functions-api.ts index e685aeb99b..4b4dafa337 100644 --- a/src/functions/functions-api.ts +++ b/src/functions/functions-api.ts @@ -104,3 +104,16 @@ export interface TaskOptionsExperimental { */ uri?: string; } + +/** + * Type representing the scope of a function in a task queue. + */ +export type FunctionScope = { + scope: 'current'; +} | { + scope: 'global'; +} | { + scope: 'extension'; + instance: string; +}; + diff --git a/src/functions/functions.ts b/src/functions/functions.ts index 805654aaa6..ff882c3685 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -16,9 +16,9 @@ */ import { App } from '../app'; -import { FunctionsApiClient } from './functions-api-client-internal'; +import { FunctionsApiClient, InternalFunctionScope } from './functions-api-client-internal'; import { FirebaseFunctionsError } from './error'; -import { TaskOptions } from './functions-api'; +import { TaskOptions, FunctionScope } from './functions-api'; import * as validator from '../utils/validator'; /** @@ -29,10 +29,10 @@ export class Functions { private readonly client: FunctionsApiClient; /** - * @param app - The app for this `Functions` service. - * @constructor - * @internal - */ + * @param app - The app for this `Functions` service. + * @constructor + * @internal + */ constructor(readonly app: App) { this.client = new FunctionsApiClient(app); } @@ -53,11 +53,35 @@ export class Functions { * `{functionName}` * * @param functionName - The name of the function. - * @param extensionId - Optional Firebase extension ID. + * @param scope - Optional FunctionScope configuration. Only needed if targeting a scope other than the current one. + * @returns A promise that fulfills with a `TaskQueue`. + */ + public taskQueue>( + functionName: string, + scope?: FunctionScope + ): TaskQueue; + /** + * Creates a reference to a {@link TaskQueue} for a given function name. + * + * @param functionName - The name of the function. + * @param deprecatedExtensionId - Optional Firebase extension ID. * @returns A promise that fulfills with a `TaskQueue`. + * @deprecated Use scope parameter instead. */ - public taskQueue>(functionName: string, extensionId?: string): TaskQueue { - return new TaskQueue(functionName, this.client, extensionId); + public taskQueue>( + functionName: string, + deprecatedExtensionId: string + ): TaskQueue; + public taskQueue>( + functionName: string, + extensionIdOrScope: string | FunctionScope = { scope: 'current' } + ): TaskQueue { + const scope: FunctionScope = typeof extensionIdOrScope === 'string' + ? (extensionIdOrScope === '' + ? { scope: 'current' } + : { scope: 'extensionOrKit' as any, instance: extensionIdOrScope }) + : extensionIdOrScope; + return new TaskQueue(functionName, this.client, scope); } } @@ -66,15 +90,45 @@ export class Functions { */ export class TaskQueue> { + private readonly scope: InternalFunctionScope; + /** * @param functionName - The name of the function. * @param client - The `FunctionsApiClient` instance. - * @param extensionId - Optional canonical ID of the extension. + * @param scope - Optional FunctionScope configuration. Only needed if targeting a scope other than the current one. * @constructor * @internal */ - constructor(private readonly functionName: string, private readonly client: FunctionsApiClient, - private readonly extensionId?: string) { + constructor( + functionName: string, + client: FunctionsApiClient, + scope?: FunctionScope + ); + /** + * @param functionName - The name of the function. + * @param client - The `FunctionsApiClient` instance. + * @param deprecatedExtensionId - Optional canonical ID of the extension. + * @constructor + * @internal + * @deprecated Use scope parameter instead. + */ + constructor( + functionName: string, + client: FunctionsApiClient, + deprecatedExtensionId: string + ); + /** + * @param functionName - The name of the function. + * @param client - The `FunctionsApiClient` instance. + * @param extensionIdOrScope - Optional canonical ID of the extension or FunctionScope. + * @constructor + * @internal + */ + constructor( + private readonly functionName: string, + private readonly client: FunctionsApiClient, + extensionIdOrScope: string | InternalFunctionScope = { scope: 'current' } + ) { if (!validator.isNonEmptyString(functionName)) { throw new FirebaseFunctionsError({ code: 'invalid-argument', @@ -87,12 +141,64 @@ export class TaskQueue> { message: 'Must provide a valid FunctionsApiClient instance to create a new TaskQueue.' }); } - if (typeof extensionId !== 'undefined' && !validator.isString(extensionId)) { + + if (typeof extensionIdOrScope === 'string') { + if (!validator.isString(extensionIdOrScope)) { + throw new FirebaseFunctionsError({ + code: 'invalid-argument', + message: '`extensionId` must be a string.' + }); + } + this.scope = extensionIdOrScope === '' + ? { scope: 'current' } + : { scope: 'extensionOrKit', instance: extensionIdOrScope }; + } else if (validator.isNonNullObject(extensionIdOrScope)) { + const scope = (extensionIdOrScope as any).scope; + if (scope === 'current' || scope === 'global') { + this.scope = extensionIdOrScope as InternalFunctionScope; + } else if (scope === 'extension' || scope === 'kit' || scope === 'extensionOrKit') { + const instance = (extensionIdOrScope as any).instance; + if (!validator.isNonEmptyString(instance)) { + throw new FirebaseFunctionsError({ + code: 'invalid-argument', + message: `\`instance\` must be a non-empty string for scope "${scope}".` + }); + } + this.scope = extensionIdOrScope as InternalFunctionScope; + } else { + // TODO: Update error message to include 'kit' when kit type becomes public. + throw new FirebaseFunctionsError({ + code: 'invalid-argument', + message: '`scope` must be one of "current", "global", or "extension".' + }); + } + } else { throw new FirebaseFunctionsError({ code: 'invalid-argument', - message: '`extensionId` must be a string.' + message: '`extensionIdOrScope` must be a string or a FunctionScope object.' }); } + + if (this.scope.scope === 'extensionOrKit') { + const instance = this.scope.instance; + const extInstanceId = process.env.EXT_INSTANCE_ID; + const kitInstanceId = process.env.KIT_INSTANCE_ID; + if (validator.isNonEmptyString(extInstanceId) && extInstanceId === instance) { + (this.scope as any).scope = 'extension'; + console.warn( + 'Targeting your own extension no longer requires a second parameter. ' + + `Please change the call taskQueue('${functionName}', '${instance}') to taskQueue('${functionName}')` + ); + } else if (validator.isNonEmptyString(kitInstanceId) && kitInstanceId === instance) { + (this.scope as any).scope = 'kit'; + console.warn( + 'Targeting your own extension or kit no longer requires a second parameter, ' + + 'which can have peformance implications. Please change the call ' + + `taskQueue('${functionName}', '${instance}') to taskQueue('${functionName}') ` + + `or taskQueue('${functionName}', { scope: "self" })` + ); + } + } } /** @@ -103,8 +209,30 @@ export class TaskQueue> { * @param opts - Optional options when enqueuing a new task. * @returns A promise that resolves when the task has successfully been added to the queue. */ - public enqueue(data: Args, opts?: TaskOptions): Promise { - return this.client.enqueue(data, this.functionName, this.extensionId, opts); + public async enqueue(data: Args, opts?: TaskOptions): Promise { + if (this.scope.scope !== 'extensionOrKit') { + await this.client.enqueue(data, this.functionName, this.scope, opts); + return; + } + + // Note: The extensionOrKit scope and the associated fallback logic exist purely + // to support migration from legacy extension ID string parameters, and can be + // removed in the next major version release after extensions turndown. + try { + await this.client.enqueue(data, this.functionName, this.scope, opts); + } catch (err: any) { + const isNotFound = err.code === 'not-found' || + (err instanceof FirebaseFunctionsError && err.httpResponse?.status === 404); + if (isNotFound) { + const tempKitScope = { scope: 'kit' as const, instance: this.scope.instance }; + await this.client.enqueue(data, this.functionName, tempKitScope, opts); + // Only upgrade the stateful scope to kit if the retry request succeeds + (this.scope as any).scope = 'kit'; + this.logFallbackWarning(this.functionName, this.scope.instance); + return; + } + throw err; + } } /** @@ -112,7 +240,68 @@ export class TaskQueue> { * @param id - the ID of the task, relative to this queue. * @returns A promise that resolves when the task has been deleted. */ - public delete(id: string): Promise { - return this.client.delete(id, this.functionName, this.extensionId); + public async delete(id: string): Promise { + if (this.scope.scope !== 'extensionOrKit') { + try { + await this.client.delete(id, this.functionName, this.scope); + } catch (err: any) { + const isNotFound = err.code === 'not-found' || + (err instanceof FirebaseFunctionsError && err.httpResponse?.status === 404); + if (isNotFound) { + return; + } + throw err; + } + return; + } + + // Note: The extensionOrKit scope and the associated fallback logic exist purely + // to support migration from legacy extension ID string parameters, and can be + // removed in the next major version release after extensions turndown. + try { + await this.client.delete(id, this.functionName, this.scope); + } catch (err: any) { + const isNotFound = err.code === 'not-found' || + (err instanceof FirebaseFunctionsError && err.httpResponse?.status === 404); + if (isNotFound) { + try { + const tempKitScope = { scope: 'kit' as const, instance: this.scope.instance }; + await this.client.delete(id, this.functionName, tempKitScope); + // Only upgrade the stateful scope to kit if the retry request succeeds + (this.scope as any).scope = 'kit'; + this.logFallbackWarning(this.functionName, this.scope.instance); + } catch (retryErr: any) { + const isRetryNotFound = retryErr.code === 'not-found' || + (retryErr instanceof FirebaseFunctionsError && retryErr.httpResponse?.status === 404); + if (isRetryNotFound) { + // Ignore if both fail with not-found + return; + } + throw retryErr; + } + return; + } + throw err; + } + } + + private logFallbackWarning(functionName: string, instance: string): void { + const kitInstanceId = process.env.KIT_INSTANCE_ID; + if (validator.isNonEmptyString(kitInstanceId) && kitInstanceId === instance) { + // Note: It is OK to warn about kits here because targeting a kit requires the kit to be deployed first. + console.warn( + 'Targeting your own extension or kit no longer requires a second parameter, ' + + 'which can have peformance implications. Please change the call ' + + `taskQueue('${functionName}', '${instance}') to taskQueue('${functionName}') ` + + `or taskQueue('${functionName}', { scope: "self" })` + ); + } else { + console.warn( + `Targeting kit ${instance} with the legacy extensions API, ` + + 'which has performance implications. Please change the call ' + + `taskQueue('${functionName}', '${instance}') to ` + + `taskQueue('${functionName}', { scope: "kit", instance: '${instance}' })` + ); + } } } diff --git a/src/functions/index.ts b/src/functions/index.ts index 0894d244a6..8e7d982638 100644 --- a/src/functions/index.ts +++ b/src/functions/index.ts @@ -31,6 +31,7 @@ export { DeliverySchedule, TaskOptions, TaskOptionsExperimental, + FunctionScope, } from './functions-api'; export { Functions, diff --git a/test/unit/functions/functions-api-client-internal.spec.ts b/test/unit/functions/functions-api-client-internal.spec.ts index 3386016bdd..350b51c411 100644 --- a/test/unit/functions/functions-api-client-internal.spec.ts +++ b/test/unit/functions/functions-api-client-internal.spec.ts @@ -26,6 +26,7 @@ import { getSdkVersion, getMetricsHeader } from '../../../src/utils'; import { FirebaseApp } from '../../../src/app/firebase-app'; import { FunctionsApiClient, Task } from '../../../src/functions/functions-api-client-internal'; +import { TaskQueue } from '../../../src/functions/functions'; import { FirebaseFunctionsError } from '../../../src/functions/error'; import { HttpClient } from '../../../src/utils/api-request'; import { toHttpResponse } from '../../../src/utils/error'; @@ -218,21 +219,21 @@ describe('FunctionsApiClient', () => { for (const invalidOption of [null, 'abc', '', [], true, 102, 1.2]) { it(`should throw if options is ${invalidOption}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', invalidOption as any)) + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, invalidOption as any)) .to.eventually.throw('TaskOptions must be a non-null object'); }); } for (const invalidScheduleTime of [null, '', 'abc', 102, 1.2, [], {}, true, NaN]) { it(`should throw if scheduleTime is ${invalidScheduleTime}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleTime: invalidScheduleTime } as any)) + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { scheduleTime: invalidScheduleTime } as any)) .to.eventually.throw('scheduleTime must be a valid Date object.'); }); } for (const invalidScheduleDelaySeconds of [null, 'abc', '', [], {}, true, NaN, -1]) { it(`should throw if scheduleDelaySeconds is ${invalidScheduleDelaySeconds}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { scheduleDelaySeconds: invalidScheduleDelaySeconds } as any)) .to.eventually.throw('scheduleDelaySeconds must be a non-negative duration in seconds.'); }); @@ -240,7 +241,7 @@ describe('FunctionsApiClient', () => { for (const invalidDispatchDeadlineSeconds of [null, 'abc', '', [], {}, true, NaN, -1, 14, 1801]) { it(`should throw if dispatchDeadlineSeconds is ${invalidDispatchDeadlineSeconds}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { dispatchDeadlineSeconds: invalidDispatchDeadlineSeconds } as any)) .to.eventually.throw('dispatchDeadlineSeconds must be a non-negative duration in seconds ' + 'and must be in the range of 15s to 30 mins.'); @@ -249,7 +250,7 @@ describe('FunctionsApiClient', () => { for (const invalidUri of [null, '', 'a', 'foo', 'image.jpg', [], {}, true, NaN]) { it(`should throw given an invalid uri: ${invalidUri}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { uri: invalidUri } as any)) .to.eventually.throw('uri must be a valid URL string.'); }); @@ -257,7 +258,7 @@ describe('FunctionsApiClient', () => { for (const invalidTaskId of [1234, 'task!', 'id:0', '[1234]', '(1234)']) { it(`should throw given an invalid task ID: ${invalidTaskId}`, () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { id: invalidTaskId } as any )) .to.eventually.throw('id can contain only letters ([A-Za-z]), numbers ([0-9]), ' + 'hyphens (-), or underscores (_). The maximum length is 500 characters.') @@ -265,7 +266,7 @@ describe('FunctionsApiClient', () => { } it('should throw when both scheduleTime and scheduleDelaySeconds are provided', () => { - expect(apiClient.enqueue({}, FUNCTION_NAME, '', { + expect(apiClient.enqueue({}, FUNCTION_NAME, undefined, { scheduleTime: new Date(), scheduleDelaySeconds: 1000 } as any)) @@ -427,7 +428,7 @@ describe('FunctionsApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({}, 200)); stubs.push(stub); - return apiClient.enqueue({}, FUNCTION_NAME, EXTENSION_ID) + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'extension', instance: EXTENSION_ID }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -488,7 +489,7 @@ describe('FunctionsApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({}, 200)); stubs.push(stub); - return apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleTime }) + return apiClient.enqueue({}, FUNCTION_NAME, undefined, { scheduleTime }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -515,7 +516,7 @@ describe('FunctionsApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({}, 200)); stubs.push(stub); - return apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleDelaySeconds }) + return apiClient.enqueue({}, FUNCTION_NAME, undefined, { scheduleDelaySeconds }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -537,7 +538,7 @@ describe('FunctionsApiClient', () => { .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({}, 200)); stubs.push(stub); - return apiClient.enqueue({}, FUNCTION_NAME, '', { dispatchDeadlineSeconds }) + return apiClient.enqueue({}, FUNCTION_NAME, undefined, { dispatchDeadlineSeconds }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -580,7 +581,7 @@ describe('FunctionsApiClient', () => { stubs.push(stub); process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST; apiClient = new FunctionsApiClient(app); - return apiClient.enqueue({}, FUNCTION_NAME, '', { uri: TEST_TASK_PAYLOAD.httpRequest.url }) + return apiClient.enqueue({}, FUNCTION_NAME, undefined, { uri: TEST_TASK_PAYLOAD.httpRequest.url }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -630,7 +631,7 @@ describe('FunctionsApiClient', () => { stubs.push(stub); process.env.CLOUD_TASKS_EMULATOR_HOST = CLOUD_TASKS_EMULATOR_HOST; apiClient = new FunctionsApiClient(app); - return apiClient.enqueue({}, FUNCTION_NAME, '', { uri: TEST_TASK_PAYLOAD.httpRequest.url }) + return apiClient.enqueue({}, FUNCTION_NAME, undefined, { uri: TEST_TASK_PAYLOAD.httpRequest.url }) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', @@ -713,5 +714,211 @@ describe('FunctionsApiClient', () => { .and.deep.include(expected) .and.have.property('cause', expected.cause); }); - }) + }); + + describe('taskQueue scopes', () => { + afterEach(() => { + delete process.env.EXT_INSTANCE_ID; + delete process.env.KIT_INSTANCE_ID; + delete process.env.FIREBASE_KIT_INSTANCE_ID; + }); + + it('should namespace function with ext- when scope is current and EXT_INSTANCE_ID is set', () => { + process.env.EXT_INSTANCE_ID = 'ext-inst'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'current' }) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/ext-ext-inst-function-name/tasks'); + }); + }); + + it('should namespace function with kit- when scope is current and KIT_INSTANCE_ID is set', () => { + process.env.KIT_INSTANCE_ID = 'kit-inst'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'current' }) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/kit-kit-inst-function-name/tasks'); + }); + }); + + it('should not namespace function when scope is current and no env vars are set', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'current' }) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/function-name/tasks'); + }); + }); + + it('should not namespace function when scope is global', () => { + process.env.EXT_INSTANCE_ID = 'ext-inst'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'global' }) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/function-name/tasks'); + }); + }); + + it('should namespace function with ext- when scope is extension with custom instance', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'extension', instance: 'custom-ext' }) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/ext-custom-ext-function-name/tasks'); + }); + }); + + it('should namespace function with kit- when scope is kit with custom instance', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'kit', instance: 'custom-kit' } as any) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/kit-custom-kit-function-name/tasks'); + }); + }); + + it('should succeed on first attempt and not warning when string extension parameter is used and succeeds', () => { + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .then(() => { + expect(stub).to.have.been.calledOnce; + expect(stub.firstCall.args[0].url).to.contain('/queues/ext-my-inst-function-name/tasks'); + expect(warnStub).to.not.have.been.called; + }); + }); + + it('should warn with self-targeting warning and target kit directly if KIT_INSTANCE_ID matches the string', + () => { + process.env.KIT_INSTANCE_ID = 'my-inst'; + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + + const sendStub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(sendStub); + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .then(() => { + expect(sendStub).to.have.been.calledOnce; + expect(sendStub.firstCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.equal( + 'Targeting your own extension or kit no longer requires a second parameter, ' + + 'which can have peformance implications. Please change the call ' + + `taskQueue('${FUNCTION_NAME}', 'my-inst') to taskQueue('${FUNCTION_NAME}') ` + + `or taskQueue('${FUNCTION_NAME}', { scope: "self" })` + ); + }); + }); + + it('should warn with self-targeting warning and target extension directly if EXT_INSTANCE_ID matches the string', + () => { + process.env.EXT_INSTANCE_ID = 'my-inst'; + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + + const sendStub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(sendStub); + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .then(() => { + expect(sendStub).to.have.been.calledOnce; + expect(sendStub.firstCall.args[0].url).to.contain('/queues/ext-my-inst-function-name/tasks'); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.equal( + 'Targeting your own extension no longer requires a second parameter. ' + + `Please change the call taskQueue('${FUNCTION_NAME}', 'my-inst') to taskQueue('${FUNCTION_NAME}')` + ); + }); + }); + + it('should warn with kit legacy warning if kit fallback succeeds and KIT_INSTANCE_ID does not match', () => { + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + + const sendStub = sinon.stub(HttpClient.prototype, 'send'); + sendStub.onFirstCall().rejects(utils.errorFrom({}, 404)); + sendStub.onSecondCall().resolves(utils.responseFrom({}, 200)); + stubs.push(sendStub); + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .then(() => { + expect(sendStub).to.have.been.calledTwice; + expect(sendStub.firstCall.args[0].url).to.contain('/queues/ext-my-inst-function-name/tasks'); + expect(sendStub.secondCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.equal( + 'Targeting kit my-inst with the legacy extensions API, ' + + 'which has performance implications. Please change the call ' + + `taskQueue('${FUNCTION_NAME}', 'my-inst') to ` + + `taskQueue('${FUNCTION_NAME}', { scope: "kit", instance: 'my-inst' })` + ); + }); + }); + + it('should propagate 404 error if both ext- and kit- attempts fail', () => { + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + + const sendStub = sinon.stub(HttpClient.prototype, 'send'); + sendStub.onFirstCall().rejects(utils.errorFrom({}, 404)); + sendStub.onSecondCall().rejects(utils.errorFrom({}, 404)); + stubs.push(sendStub); + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .should.eventually.be.rejectedWith(FirebaseFunctionsError) + .then(() => { + expect(sendStub).to.have.been.calledTwice; + expect(warnStub).to.not.have.been.called; + }); + }); + + it('should remember the kit fallback state and bypass ext- on subsequent enqueue calls', () => { + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + + const sendStub = sinon.stub(HttpClient.prototype, 'send'); + // First enqueue: ext- 404, kit- 200 + sendStub.onFirstCall().rejects(utils.errorFrom({}, 404)); + sendStub.onSecondCall().resolves(utils.responseFrom({}, 200)); + // Second enqueue: kit- 200 (directly) + sendStub.onThirdCall().resolves(utils.responseFrom({}, 200)); + stubs.push(sendStub); + + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); + + return queue.enqueue({}) + .then(() => { + expect(sendStub).to.have.been.calledTwice; + expect(sendStub.firstCall.args[0].url).to.contain('/queues/ext-my-inst-function-name/tasks'); + expect(sendStub.secondCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); + + return queue.enqueue({}); + }) + .then(() => { + expect(sendStub).to.have.been.calledThrice; + // Third call should target kit- directly without trying ext- + expect(sendStub.thirdCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); + expect(warnStub).to.have.been.calledOnce; // warned only once (on the fallback) + }); + }); + }); }); diff --git a/test/unit/functions/functions.spec.ts b/test/unit/functions/functions.spec.ts index 210a4905e8..89b0522287 100644 --- a/test/unit/functions/functions.spec.ts +++ b/test/unit/functions/functions.spec.ts @@ -164,6 +164,60 @@ describe('TaskQueue', () => { return new TaskQueue(FUNCTION_NAME, mockClient); }).not.to.throw(); }); + + it('should not throw given a valid scope object: current', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'current' }); + }).not.to.throw(); + }); + + it('should not throw given a valid scope object: global', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'global' }); + }).not.to.throw(); + }); + + it('should not throw given a valid scope object: extension', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'extension', instance: 'my-ext' }); + }).not.to.throw(); + }); + + it('should not throw given a valid scope object: kit (secretly accepted)', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'kit', instance: 'my-kit' } as any); + }).not.to.throw(); + }); + + it('should throw given an invalid scope object', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'invalid' } as any); + }).to.throw('`scope` must be one of "current", "global", or "extension".'); + }); + + it('should throw if instance is missing for extension scope', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'extension' } as any); + }).to.throw('`instance` must be a non-empty string for scope "extension".'); + }); + + it('should throw if instance is empty for extension scope', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'extension', instance: '' } as any); + }).to.throw('`instance` must be a non-empty string for scope "extension".'); + }); + + it('should throw if instance is missing for kit scope', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, { scope: 'kit' } as any); + }).to.throw('`instance` must be a non-empty string for scope "kit".'); + }); + + it('should throw if parameter is not a string or object', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient, 123 as any); + }).to.throw('`extensionIdOrScope` must be a string or a FunctionScope object.'); + }); }); describe('enqueue', () => { From fa363cc279004f307ff2ff2aa2e52fa89b38e850 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 9 Jul 2026 13:10:56 -0700 Subject: [PATCH 4/7] chore: address pull request review comments --- src/functions/functions-api-client-internal.ts | 15 +++++++-------- src/functions/functions.ts | 14 ++++---------- .../functions-api-client-internal.spec.ts | 6 +++--- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 5a60771582..0ccf352cc6 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -220,8 +220,7 @@ export class FunctionsApiClient { return this.resolveResourceId(resourceId, { scope: 'current' }); } - const scopeObj = scope as any; - switch (scopeObj.scope) { + switch (scope.scope) { case 'current': { const extInstanceId = process.env.EXT_INSTANCE_ID; if (validator.isNonEmptyString(extInstanceId)) { @@ -243,18 +242,18 @@ export class FunctionsApiClient { return { resourceId }; case 'extension': return { - resourceId: `ext-${scopeObj.instance}-${resourceId}`, - extensionOrKitId: scopeObj.instance, + resourceId: `ext-${scope.instance}-${resourceId}`, + extensionOrKitId: scope.instance, }; case 'kit': // kit scope is secretly accepted for forward compatibility return { - resourceId: `kit-${scopeObj.instance}-${resourceId}`, - extensionOrKitId: scopeObj.instance, + resourceId: `kit-${scope.instance}-${resourceId}`, + extensionOrKitId: scope.instance, }; case 'extensionOrKit': return { - resourceId: `ext-${scopeObj.instance}-${resourceId}`, - extensionOrKitId: scopeObj.instance, + resourceId: `ext-${scope.instance}-${resourceId}`, + extensionOrKitId: scope.instance, }; default: return { resourceId }; diff --git a/src/functions/functions.ts b/src/functions/functions.ts index ff882c3685..6abd61f996 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -143,12 +143,6 @@ export class TaskQueue> { } if (typeof extensionIdOrScope === 'string') { - if (!validator.isString(extensionIdOrScope)) { - throw new FirebaseFunctionsError({ - code: 'invalid-argument', - message: '`extensionId` must be a string.' - }); - } this.scope = extensionIdOrScope === '' ? { scope: 'current' } : { scope: 'extensionOrKit', instance: extensionIdOrScope }; @@ -193,9 +187,9 @@ export class TaskQueue> { (this.scope as any).scope = 'kit'; console.warn( 'Targeting your own extension or kit no longer requires a second parameter, ' + - 'which can have peformance implications. Please change the call ' + + 'which can have performance implications. Please change the call ' + `taskQueue('${functionName}', '${instance}') to taskQueue('${functionName}') ` + - `or taskQueue('${functionName}', { scope: "self" })` + `or taskQueue('${functionName}', { scope: "current" })` ); } } @@ -291,9 +285,9 @@ export class TaskQueue> { // Note: It is OK to warn about kits here because targeting a kit requires the kit to be deployed first. console.warn( 'Targeting your own extension or kit no longer requires a second parameter, ' + - 'which can have peformance implications. Please change the call ' + + 'which can have performance implications. Please change the call ' + `taskQueue('${functionName}', '${instance}') to taskQueue('${functionName}') ` + - `or taskQueue('${functionName}', { scope: "self" })` + `or taskQueue('${functionName}', { scope: "current" })` ); } else { console.warn( diff --git a/test/unit/functions/functions-api-client-internal.spec.ts b/test/unit/functions/functions-api-client-internal.spec.ts index 350b51c411..e5b30b88b4 100644 --- a/test/unit/functions/functions-api-client-internal.spec.ts +++ b/test/unit/functions/functions-api-client-internal.spec.ts @@ -818,9 +818,9 @@ describe('FunctionsApiClient', () => { expect(warnStub).to.have.been.calledOnce; expect(warnStub.firstCall.args[0]).to.equal( 'Targeting your own extension or kit no longer requires a second parameter, ' + - 'which can have peformance implications. Please change the call ' + - `taskQueue('${FUNCTION_NAME}', 'my-inst') to taskQueue('${FUNCTION_NAME}') ` + - `or taskQueue('${FUNCTION_NAME}', { scope: "self" })` + 'which can have performance implications. Please change the call ' + + `taskQueue('${FUNCTION_NAME}', 'my-inst') to taskQueue('${FUNCTION_NAME}') ` + + `or taskQueue('${FUNCTION_NAME}', { scope: "current" })` ); }); }); From 94f6d08b7f73a7d479ea489fcd26b0492b3d8688 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 10 Jul 2026 08:05:19 -0700 Subject: [PATCH 5/7] chore(functions): Rename KIT_INSTANCE_ID to FIREBASE_KIT_INSTANCE_ID --- .../functions-api-client-internal.ts | 2 +- src/functions/functions.ts | 4 +- .../functions-api-client-internal.spec.ts | 45 ++++++++++--------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 0ccf352cc6..2379d36fdb 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -229,7 +229,7 @@ export class FunctionsApiClient { extensionOrKitId: extInstanceId, }; } - const kitInstanceId = process.env.KIT_INSTANCE_ID; + const kitInstanceId = process.env.FIREBASE_KIT_INSTANCE_ID; if (validator.isNonEmptyString(kitInstanceId)) { return { resourceId: `kit-${kitInstanceId}-${resourceId}`, diff --git a/src/functions/functions.ts b/src/functions/functions.ts index 6abd61f996..38a1420c27 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -176,7 +176,7 @@ export class TaskQueue> { if (this.scope.scope === 'extensionOrKit') { const instance = this.scope.instance; const extInstanceId = process.env.EXT_INSTANCE_ID; - const kitInstanceId = process.env.KIT_INSTANCE_ID; + const kitInstanceId = process.env.FIREBASE_KIT_INSTANCE_ID; if (validator.isNonEmptyString(extInstanceId) && extInstanceId === instance) { (this.scope as any).scope = 'extension'; console.warn( @@ -280,7 +280,7 @@ export class TaskQueue> { } private logFallbackWarning(functionName: string, instance: string): void { - const kitInstanceId = process.env.KIT_INSTANCE_ID; + const kitInstanceId = process.env.FIREBASE_KIT_INSTANCE_ID; if (validator.isNonEmptyString(kitInstanceId) && kitInstanceId === instance) { // Note: It is OK to warn about kits here because targeting a kit requires the kit to be deployed first. console.warn( diff --git a/test/unit/functions/functions-api-client-internal.spec.ts b/test/unit/functions/functions-api-client-internal.spec.ts index e5b30b88b4..06d0f7b0d9 100644 --- a/test/unit/functions/functions-api-client-internal.spec.ts +++ b/test/unit/functions/functions-api-client-internal.spec.ts @@ -719,7 +719,6 @@ describe('FunctionsApiClient', () => { describe('taskQueue scopes', () => { afterEach(() => { delete process.env.EXT_INSTANCE_ID; - delete process.env.KIT_INSTANCE_ID; delete process.env.FIREBASE_KIT_INSTANCE_ID; }); @@ -734,8 +733,8 @@ describe('FunctionsApiClient', () => { }); }); - it('should namespace function with kit- when scope is current and KIT_INSTANCE_ID is set', () => { - process.env.KIT_INSTANCE_ID = 'kit-inst'; + it('should namespace function with kit- when scope is current and FIREBASE_KIT_INSTANCE_ID is set', () => { + process.env.FIREBASE_KIT_INSTANCE_ID = 'kit-inst'; const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); stubs.push(stub); return apiClient.enqueue({}, FUNCTION_NAME, { scope: 'current' }) @@ -801,29 +800,30 @@ describe('FunctionsApiClient', () => { }); }); - it('should warn with self-targeting warning and target kit directly if KIT_INSTANCE_ID matches the string', - () => { - process.env.KIT_INSTANCE_ID = 'my-inst'; - const warnStub = sinon.stub(console, 'warn'); - stubs.push(warnStub); + it('should warn with self-targeting warning and target kit directly if ' + + 'FIREBASE_KIT_INSTANCE_ID matches the string', + () => { + process.env.FIREBASE_KIT_INSTANCE_ID = 'my-inst'; + const warnStub = sinon.stub(console, 'warn'); + stubs.push(warnStub); - const sendStub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); - stubs.push(sendStub); + const sendStub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({}, 200)); + stubs.push(sendStub); - const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); - return queue.enqueue({}) - .then(() => { - expect(sendStub).to.have.been.calledOnce; - expect(sendStub.firstCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); - expect(warnStub).to.have.been.calledOnce; - expect(warnStub.firstCall.args[0]).to.equal( - 'Targeting your own extension or kit no longer requires a second parameter, ' + + const queue = new TaskQueue(FUNCTION_NAME, apiClient, 'my-inst'); + return queue.enqueue({}) + .then(() => { + expect(sendStub).to.have.been.calledOnce; + expect(sendStub.firstCall.args[0].url).to.contain('/queues/kit-my-inst-function-name/tasks'); + expect(warnStub).to.have.been.calledOnce; + expect(warnStub.firstCall.args[0]).to.equal( + 'Targeting your own extension or kit no longer requires a second parameter, ' + 'which can have performance implications. Please change the call ' + `taskQueue('${FUNCTION_NAME}', 'my-inst') to taskQueue('${FUNCTION_NAME}') ` + `or taskQueue('${FUNCTION_NAME}', { scope: "current" })` - ); - }); - }); + ); + }); + }); it('should warn with self-targeting warning and target extension directly if EXT_INSTANCE_ID matches the string', () => { @@ -847,7 +847,8 @@ describe('FunctionsApiClient', () => { }); }); - it('should warn with kit legacy warning if kit fallback succeeds and KIT_INSTANCE_ID does not match', () => { + it('should warn with kit legacy warning if kit fallback succeeds and ' + + 'FIREBASE_KIT_INSTANCE_ID does not match', () => { const warnStub = sinon.stub(console, 'warn'); stubs.push(warnStub); From fb67f7f19f36e8f7f90a36547b10b7a4278865d5 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Mon, 13 Jul 2026 09:48:29 -0700 Subject: [PATCH 6/7] chore(functions): address second round of review comments for type safety and signature simplification --- src/functions/functions-api-client-internal.ts | 5 +---- src/functions/functions.ts | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 2379d36fdb..7ebcfe55f2 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -214,11 +214,8 @@ export class FunctionsApiClient { private resolveResourceId( resourceId: string, - scope?: InternalFunctionScope + scope: InternalFunctionScope = { scope: 'current' } ): { resourceId: string; extensionOrKitId?: string } { - if (typeof scope === 'undefined') { - return this.resolveResourceId(resourceId, { scope: 'current' }); - } switch (scope.scope) { case 'current': { diff --git a/src/functions/functions.ts b/src/functions/functions.ts index 38a1420c27..284fbde7e7 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -76,10 +76,10 @@ export class Functions { functionName: string, extensionIdOrScope: string | FunctionScope = { scope: 'current' } ): TaskQueue { - const scope: FunctionScope = typeof extensionIdOrScope === 'string' + const scope: InternalFunctionScope = typeof extensionIdOrScope === 'string' ? (extensionIdOrScope === '' ? { scope: 'current' } - : { scope: 'extensionOrKit' as any, instance: extensionIdOrScope }) + : { scope: 'extensionOrKit', instance: extensionIdOrScope }) : extensionIdOrScope; return new TaskQueue(functionName, this.client, scope); } @@ -102,7 +102,7 @@ export class TaskQueue> { constructor( functionName: string, client: FunctionsApiClient, - scope?: FunctionScope + scope?: FunctionScope | InternalFunctionScope ); /** * @param functionName - The name of the function. From 4adf2f181cc12ffe2568d7519ca43d8126a635d7 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 15 Jul 2026 13:24:39 -0700 Subject: [PATCH 7/7] chore(functions): address Technical Writer review comments on docstrings --- src/functions/functions-api-client-internal.ts | 8 ++++---- src/functions/functions.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 7ebcfe55f2..191993966f 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -73,8 +73,8 @@ export class FunctionsApiClient { * Deletes a task from a queue. * * @param id - The ID of the task to delete. - * @param functionName - The function name of the queue. - * @param scope - Optional FunctionScope configuration. + * @param functionName - The name of the function associated with the queue. + * @param scope - Optional `FunctionScope` configuration. */ public async delete( id: string, @@ -139,8 +139,8 @@ export class FunctionsApiClient { * Creates a task and adds it to a queue. * * @param data - The data payload of the task. - * @param functionName - The functionName of the queue. - * @param scope - Optional FunctionScope configuration. + * @param functionName - The name of the function associated with the queue. + * @param scope - Optional `FunctionScope` configuration. * @param opts - Optional options when enqueuing a new task. */ public async enqueue( diff --git a/src/functions/functions.ts b/src/functions/functions.ts index 284fbde7e7..ddcd7d9bdf 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -53,7 +53,7 @@ export class Functions { * `{functionName}` * * @param functionName - The name of the function. - * @param scope - Optional FunctionScope configuration. Only needed if targeting a scope other than the current one. + * @param scope - Optional `FunctionScope` configuration. Only needed if targeting a scope other than the current one. * @returns A promise that fulfills with a `TaskQueue`. */ public taskQueue>( @@ -95,7 +95,7 @@ export class TaskQueue> { /** * @param functionName - The name of the function. * @param client - The `FunctionsApiClient` instance. - * @param scope - Optional FunctionScope configuration. Only needed if targeting a scope other than the current one. + * @param scope - Optional `FunctionScope` configuration. Only needed if targeting a scope other than the current one. * @constructor * @internal */ @@ -120,7 +120,7 @@ export class TaskQueue> { /** * @param functionName - The name of the function. * @param client - The `FunctionsApiClient` instance. - * @param extensionIdOrScope - Optional canonical ID of the extension or FunctionScope. + * @param extensionIdOrScope - Optional canonical ID of the extension or `FunctionScope`. * @constructor * @internal */