diff --git a/entrypoints.json b/entrypoints.json index 6b507911ae..dd17bd46fb 100644 --- a/entrypoints.json +++ b/entrypoints.json @@ -20,6 +20,10 @@ "typings": "./lib/database/index.d.ts", "dist": "./lib/database/index.js" }, + "firebase-admin/extensions": { + "typings": "./lib/extensions/index.d.ts", + "dist": "./lib/extensions/index.js" + }, "firebase-admin/firestore": { "typings": "./lib/firestore/index.d.ts", "dist": "./lib/firestore/index.js" diff --git a/etc/firebase-admin.extensions.api.md b/etc/firebase-admin.extensions.api.md new file mode 100644 index 0000000000..ed9090aaee --- /dev/null +++ b/etc/firebase-admin.extensions.api.md @@ -0,0 +1,24 @@ +## API Report File for "firebase-admin.extensions" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +/// + +import { Agent } from 'http'; + +// @public +export class Extensions { + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly app: App; + // Warning: (ae-forgotten-export) The symbol "Runtime" needs to be exported by the entry point index.d.ts + runtime(): Runtime; +} + +// @public +export function getExtensions(app?: App): Extensions; + +``` diff --git a/package.json b/package.json index 00cdee8e35..d5f678f5ec 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,9 @@ "eventarc": [ "lib/eventarc" ], + "extensions": [ + "lib/extensions" + ], "database": [ "lib/database" ], @@ -136,6 +139,11 @@ "require": "./lib/eventarc/index.js", "import": "./lib/esm/eventarc/index.js" }, + "./extensions": { + "types": "./lib/extensions/index.d.ts", + "require": "./lib/extensions/index.js", + "import": "./lib/esm/extensions/index.js" + }, "./firestore": { "types": "./lib/firestore/index.d.ts", "require": "./lib/firestore/index.js", diff --git a/src/extensions/extensions-api-client-internal.ts b/src/extensions/extensions-api-client-internal.ts new file mode 100644 index 0000000000..407dd5a82e --- /dev/null +++ b/src/extensions/extensions-api-client-internal.ts @@ -0,0 +1,150 @@ +/*! + * @license + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { AuthorizedHttpClient, HttpClient, HttpError, HttpRequestConfig } from '../utils/api-request'; +import { FirebaseAppError, PrefixedFirebaseError } from '../utils/error'; +import * as validator from '../utils/validator'; +import * as utils from '../utils'; + +const FIREBASE_FUNCTIONS_CONFIG_HEADERS = { + 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` +}; +const EXTENSIONS_API_VERSION = 'v1beta'; +// Note - use getExtensionsApiUri() instead so that changing environments is consistent. +const EXTENSIONS_URL = 'https://firebaseextensions.googleapis.com'; + +/** + * Class that facilitates sending requests to the Firebase Extensions backend API. + * + * @internal + */ +export class ExtensionsApiClient { + private readonly httpClient: HttpClient; + + constructor(private readonly app: App) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseAppError( + 'invalid-argument', + 'First argument passed to getExtensions() must be a valid Firebase app instance.'); + } + this.httpClient = new AuthorizedHttpClient(this.app as FirebaseApp); + } + + async updateRuntimeData( + projectId: string, + instanceId: string, + runtimeData: RuntimeData + ): Promise { + const url = this.getRuntimeDataUri(projectId, instanceId); + const request: HttpRequestConfig = { + method: 'PATCH', + url, + headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS, + data: runtimeData, + }; + try { + const res = await this.httpClient.send(request); + return res.data + } catch (err: any) { + throw this.toFirebaseError(err); + } + } + + private getExtensionsApiUri(): string { + return process.env['FIREBASE_EXT_URL'] ?? EXTENSIONS_URL; + } + + private getRuntimeDataUri(projectId: string, instanceId: string): string { + return `${ + this.getExtensionsApiUri() + }/${EXTENSIONS_API_VERSION}/projects/${projectId}/instances/${instanceId}/runtimeData`; + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response?.isJson()) { + return new FirebaseExtensionsError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + const error = response.data?.error; + const message = error?.message || `Unknown server error: ${response.text}`; + switch (error.code) { + case 403: + return new FirebaseExtensionsError('forbidden', message); + case 404: + return new FirebaseExtensionsError('not-found', message); + case 500: + return new FirebaseExtensionsError('internal-error', message); + } + return new FirebaseExtensionsError('unknown-error', message); + } +} + +interface RuntimeData { + + //oneof + processingState?: ProcessingState; + fatalError?: FatalError; +} + +interface RuntimeDataResponse extends RuntimeData{ + name: string, + updateTime: string, +} + +interface FatalError { + errorMessage: string; +} + +interface ProcessingState { + detailMessage: string; + state: State; +} + +type State = 'STATE_UNSPECIFIED' | + 'NONE' | + 'PROCESSING' | + 'PROCESSING_COMPLETE' | + 'PROCESSING_WARNING' | + 'PROCESSING_FAILED'; + +type ExtensionsErrorCode = 'invalid-argument' | 'not-found' | 'forbidden' | 'internal-error' | 'unknown-error'; +/** + * Firebase Extensions error code structure. This extends PrefixedFirebaseError. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class FirebaseExtensionsError extends PrefixedFirebaseError { + constructor(code: ExtensionsErrorCode, message: string) { + super('Extensions', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseExtensionsError.prototype; + } +} diff --git a/src/extensions/extensions-api.ts b/src/extensions/extensions-api.ts new file mode 100644 index 0000000000..b5ef4fcb56 --- /dev/null +++ b/src/extensions/extensions-api.ts @@ -0,0 +1,29 @@ +/*! + * @license + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * SettableProcessingState represents all the Processing states that can be set on an ExtensionInstance's runtimeData. + * + * - NONE: No relevant lifecycle event work has been done. Set this to clear out old statuses. + * - PROCESSING_COMPLETE: Lifecycle event work completed with no errors. + * - PROCESSING_WARNING: Lifecycle event work succeeded partially, + * or something happened that the user should be warned about. + * - PROCESSING_FAILED: Lifecycle event work failed completely, + * but the instance will still work correctly going forward. + * - If the extension instance is in a broken state due to the errors, instead set FatalError. + */ +export type SettableProcessingState = 'NONE' | 'PROCESSING_COMPLETE' | 'PROCESSING_WARNING' | 'PROCESSING_FAILED'; diff --git a/src/extensions/extensions.ts b/src/extensions/extensions.ts new file mode 100644 index 0000000000..6e98f3575c --- /dev/null +++ b/src/extensions/extensions.ts @@ -0,0 +1,138 @@ +/*! + * @license + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from '../app'; +import { SettableProcessingState } from './extensions-api'; +import { ExtensionsApiClient, FirebaseExtensionsError } from './extensions-api-client-internal'; +import * as validator from '../utils/validator'; + +/** + * The Firebase `Extensions` service interface. + */ +export class Extensions { + private readonly client: ExtensionsApiClient; + /** + * @param app - The app for this `Extensions` service. + * @constructor + * @internal + */ + constructor(readonly app: App) { + this.client = new ExtensionsApiClient(app); + } + + /** + * The runtime() method returns a new Runtime, which provides methods to modify an extension instance's runtime data. + * + * @returns A new Runtime object. + */ + public runtime(): Runtime { + return new Runtime(this.client); + } +} + +/** + * Runtime provides methods to modify an extension instance's runtime data. + */ +class Runtime { + private projectId: string; + private extensionInstanceId: string; + private readonly client: ExtensionsApiClient; + /** + * @param client - The API client for this `Runtime` service. + * @constructor + * @internal + */ + constructor(client: ExtensionsApiClient) { + this.projectId = this.getProjectId(); + if (!validator.isNonEmptyString(process.env['EXT_INSTANCE_ID'])) { + throw new FirebaseExtensionsError( + 'invalid-argument', + 'Runtime is only available from within a running Extension instance.' + ); + } + this.extensionInstanceId = process.env['EXT_INSTANCE_ID']; + if (!validator.isNonNullObject(client) || !('updateRuntimeData' in client)) { + throw new FirebaseExtensionsError( + 'invalid-argument', + 'Must provide a valid ExtensionsApiClient instance to create a new Runtime.'); + } + this.client = client; + } + + /** + * Sets the processing state of an extension instance. + * + * Use this method to report the results of a lifecycle event handler. If the + * lifecycle event failed & the extension instance will no longer work + * correctly, use `setFatalError` instead. + * + * @param state - The state to set the instance to. + * @param detailMessage - A message explaining the results of the lifecycle function. + */ + public async setProcessingState(state: SettableProcessingState, detailMessage: string): Promise { + await this.client.updateRuntimeData( + this.projectId, + this.extensionInstanceId, + { + processingState: { + state, + detailMessage, + }, + }, + ); + } + + /** + * Reports a fatal error while running a lifecycle event handler. + * + * Call this method when a lifecycle event handler fails in a way that makes + * the Instance inoperable. + * If the lifecycle event failed but the instance will still work as expected, + * call `setProcessingState` with the "PROCESSING_WARNING" or + * "PROCESSING_FAILED" state instead. + * + * @param errorMessage - A message explaining what went wrong and how to fix it. + */ + public async setFatalError(errorMessage: string): Promise { + if (!validator.isNonEmptyString(errorMessage)) { + throw new FirebaseExtensionsError( + 'invalid-argument', + 'errorMessage must not be empty' + ); + } + await this.client.updateRuntimeData( + this.projectId, + this.extensionInstanceId, + { + fatalError: { + errorMessage, + }, + }, + ); + } + + private getProjectId(): string { + const projectId = process.env['PROJECT_ID']; + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseExtensionsError( + 'invalid-argument', + 'PROJECT_ID must not be undefined in Extensions runtime environment' + ); + } + return projectId; + } +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts new file mode 100644 index 0000000000..6283e630dc --- /dev/null +++ b/src/extensions/index.ts @@ -0,0 +1,63 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Firebase Extensions service. + * + * @packageDocumentation + */ + +import { App, getApp } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { Extensions } from './extensions'; + +export { Extensions } from './extensions'; + +/** + * Gets the {@link Extensions} service for the default app + * or a given app. + * + * `getExtensions()` can be called with no arguments to access the default + * app's `Extensions` service or as `getExtensions(app)` to access the + * `Extensions` service associated with a specific app. + * + * @example + * ```javascript + * // Get the `Extensions` service for the default app + * const defaultExtensions = getExtensions(); + * ``` + * + * @example + * ```javascript + * // Get the `Extensions` service for a given app + * const otherExtensions = getExtensions(otherApp); + * ``` + * + * @param app - Optional app for which to return the `Extensions` service. + * If not provided, the default `Extensions` service is returned. + * + * @returns The default `Extensions` service if no app is provided, or the `Extensions` + * service associated with the provided app. + */ +export function getExtensions(app?: App): Extensions { + if (typeof app === 'undefined') { + app = getApp(); + } + + const firebaseApp: FirebaseApp = app as FirebaseApp; + return firebaseApp.getOrInitService('extensions', (app) => new Extensions(app)); +} diff --git a/test/unit/extensions/extensions-api-client-internal.spec.ts b/test/unit/extensions/extensions-api-client-internal.spec.ts new file mode 100644 index 0000000000..a95baff154 --- /dev/null +++ b/test/unit/extensions/extensions-api-client-internal.spec.ts @@ -0,0 +1,90 @@ +/*! + * @license + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { ExtensionsApiClient, FirebaseExtensionsError } from '../../../src/extensions/extensions-api-client-internal'; +import { HttpClient, HttpRequestConfig } from '../../../src/utils/api-request'; +import { SettableProcessingState } from '../../../src/extensions/extensions-api'; + +const testProjectId = 'test-project'; +const testInstanceId = 'test-instance'; + +describe('Extension API client', () => { + let app: FirebaseApp; + let apiClient: ExtensionsApiClient; + + let httpClientStub: sinon.SinonStub; + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + serviceAccountId: 'service-acct@email.com' + }; + + before(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new ExtensionsApiClient(app); + }); + + after(() => { + return app.delete(); + }); + + beforeEach(() => { + httpClientStub = sinon.stub(HttpClient.prototype, 'send'); + }); + + afterEach(() => { + httpClientStub.restore(); + }); + + describe('Constructor', () => { + it('should reject when the app is null', () => { + expect(() => new ExtensionsApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to getExtensions() must be a valid Firebase app instance.'); + }); + }); + + describe('updateRuntimeData', () => { + it('should updateRuntimeData', async () => { + const testRuntimeData = { + processingState: { + state: 'PROCESSING_COMPLETE' as SettableProcessingState, + detailMessage: 'done processing', + }, + } + const expected = sinon.match((req: HttpRequestConfig) => { + const url = 'https://firebaseextensions.googleapis.com/' + + 'v1beta/projects/test-project/instances/test-instance/runtimeData'; + return req.method == 'PATCH' && req.url == url && req.data == testRuntimeData; + }, 'Incorrect URL or Method'); + httpClientStub.withArgs(expected).resolves(utils.responseFrom(testRuntimeData, 200)); + await expect(apiClient.updateRuntimeData(testProjectId, testInstanceId, testRuntimeData)) + .to.eventually.deep.equal(testRuntimeData); + }); + + it('should convert errors in FirebaseErrors', async () => { + httpClientStub.rejects(utils.errorFrom('Something went wrong', 404)); + await expect(apiClient.updateRuntimeData(testProjectId, testInstanceId, {})) + .to.eventually.be.rejectedWith(FirebaseExtensionsError); + }); + }); +}); diff --git a/test/unit/extensions/extensions.spec.ts b/test/unit/extensions/extensions.spec.ts new file mode 100644 index 0000000000..0b3ffb3004 --- /dev/null +++ b/test/unit/extensions/extensions.spec.ts @@ -0,0 +1,179 @@ +/*! + * @license + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import { expect } from 'chai'; + +import * as mocks from '../../resources/mocks'; +import * as utils from '../utils'; +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { Extensions } from '../../../src/extensions/extensions'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { HttpClient, HttpRequestConfig } from '../../../src/utils/api-request'; +import { SettableProcessingState } from '../../../src/extensions/extensions-api'; +import { FirebaseExtensionsError } from '../../../src/extensions/extensions-api-client-internal'; + +describe('Extensions', () => { + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + let extensions: Extensions; + let mockApp: FirebaseApp; + + beforeEach(() => { + mockApp = mocks.appWithOptions(mockOptions); + extensions = new Extensions(mockApp); + }); + + afterEach(() => { + return mockApp.delete(); + }); + + describe('Constructor', () => { + it('should reject when the app is null', () => { + expect(() => new Extensions(null as unknown as FirebaseApp)) + .to.throw(FirebaseAppError); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(extensions.app).to.equal(mockApp); + }); + }); + + describe('Runtime', () => { + let processEnvCopy: Record; + beforeEach(() => { + processEnvCopy = JSON.parse(JSON.stringify(process.env)) as Record; + }); + + afterEach(() => { + process.env = processEnvCopy; + }); + + describe('Constructor', () => { + it('should error if called without PROJECT_ID', () => { + process.env['EXT_INSTANCE_ID'] = 'test-instance'; + expect(() => extensions.runtime()) + .to.throw('PROJECT_ID must not be undefined in Extensions runtime environment'); + }); + + + it('should error if called without EXT_INSTANCE_ID', () => { + process.env['PROJECT_ID'] = 'test-project'; + expect(() => extensions.runtime()) + .to.throw('Runtime is only available from within a running Extension instance.'); + }); + + it('should not error if called from an extension', () => { + process.env['PROJECT_ID'] = 'test-project'; + process.env['EXT_INSTANCE_ID'] = 'test-instance'; + expect(() => extensions.runtime()).not.to.throw(); + }); + }); + + describe('setProcessingState', () => { + let httpClientStub: sinon.SinonStub; + beforeEach(() => { + process.env['PROJECT_ID'] = 'test-project'; + process.env['EXT_INSTANCE_ID'] = 'test-instance'; + httpClientStub = sinon.stub(HttpClient.prototype, 'send'); + }); + + afterEach(() => { + httpClientStub.restore(); + }); + + for (const state of ['PROCESSING_FAILED', 'PROCESSING_WARNING','PROCESSING_COMPLETE', 'NONE']) { + it(`should set ${state} state`, async () => { + const expectedRuntimeData = { + processingState: { + state: state as SettableProcessingState, + detailMessage: 'done processing', + }, + } + const expected = sinon.match((req: HttpRequestConfig) => { + const url = 'https://firebaseextensions.googleapis.com/' + + 'v1beta/projects/test-project/instances/test-instance/runtimeData'; + return req.method == 'PATCH' && + req.url == url && + JSON.stringify(req.data) == JSON.stringify(expectedRuntimeData); + }, 'Incorrect URL or Method'); + httpClientStub.withArgs(expected).resolves(utils.responseFrom(expectedRuntimeData, 200)); + + + await extensions.runtime().setProcessingState(state as SettableProcessingState, 'done processing'); + expect(httpClientStub).to.have.been.calledOnce; + }); + } + + it('should covert errors in FirebaseErrors', async () => { + httpClientStub.rejects(utils.errorFrom('Something went wrong', 404)); + await expect(extensions.runtime().setProcessingState('PROCESSING_COMPLETE', 'a message')) + .to.eventually.be.rejectedWith(FirebaseExtensionsError); + }); + }); + + describe('setFatalError', () => { + let httpClientStub: sinon.SinonStub; + beforeEach(() => { + process.env['PROJECT_ID'] = 'test-project'; + process.env['EXT_INSTANCE_ID'] = 'test-instance'; + httpClientStub = sinon.stub(HttpClient.prototype, 'send'); + }); + + afterEach(() => { + httpClientStub.restore(); + }); + + it('should set fatal error', async () => { + const expectedRuntimeData = { + fatalError: { + errorMessage: 'A bad error!', + }, + }; + const expected = sinon.match((req: HttpRequestConfig) => { + const url = 'https://firebaseextensions.googleapis.com/' + + 'v1beta/projects/test-project/instances/test-instance/runtimeData'; + return req.method == 'PATCH' && + req.url == url && + JSON.stringify(req.data) == JSON.stringify(expectedRuntimeData); + }, 'Incorrect URL or Method'); + httpClientStub.withArgs(expected).resolves(utils.responseFrom(expectedRuntimeData, 200)); + + + await extensions.runtime().setFatalError('A bad error!'); + expect(httpClientStub).to.have.been.calledOnce; + }); + + it('should error if errorMessage is empty', async () => { + await expect(extensions.runtime().setFatalError('')) + .to.eventually.be.rejectedWith(FirebaseExtensionsError, 'errorMessage must not be empty'); + }); + + it('should convert errors in FirebaseErrors', async () => { + httpClientStub.rejects(utils.errorFrom('Something went wrong', 404)); + await expect(extensions.runtime().setFatalError('a message')) + .to.eventually.be.rejectedWith(FirebaseExtensionsError); + }); + }) + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index aa7b262111..ffa46e7ef4 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -107,7 +107,12 @@ import './app-check/token-verifier.spec.ts'; // Eventarc import './eventarc/eventarc.spec'; import './eventarc/eventarc-utils.spec'; + // Functions import './functions/index.spec'; import './functions/functions.spec'; import './functions/functions-api-client-internal.spec'; + +// Extensions +import './extensions/extensions.spec'; +import './extensions/extensions-api-client-internal.spec';