diff --git a/.changeset/empty-avocados-tease.md b/.changeset/empty-avocados-tease.md new file mode 100644 index 0000000000..f202af2b82 --- /dev/null +++ b/.changeset/empty-avocados-tease.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-techdocs': patch +--- + +Internal updates to allow reusing Backstage's `fetchApi` implementation for event source requests. This allows you to for example, override the `Authorization` header. diff --git a/.changeset/orange-chefs-end.md b/.changeset/orange-chefs-end.md new file mode 100644 index 0000000000..9ba2a29685 --- /dev/null +++ b/.changeset/orange-chefs-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-oidc-provider': patch +--- + +if oidc server do not provide revocation_endpoint,we should not call revoke function diff --git a/.changeset/swift-islands-leave.md b/.changeset/swift-islands-leave.md new file mode 100644 index 0000000000..3b0b460733 --- /dev/null +++ b/.changeset/swift-islands-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Removed max width from `Select` component. diff --git a/.changeset/wise-beers-doubt.md b/.changeset/wise-beers-doubt.md new file mode 100644 index 0000000000..be70f326e0 --- /dev/null +++ b/.changeset/wise-beers-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Updated `node-gyp` to v10 diff --git a/ADOPTERS.md b/ADOPTERS.md index 2bb76a199e..5fe359bf1e 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -270,3 +270,4 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/ | [OP Financial Group](https://www.op.fi/op-financial-group) | [Heikki Hellgren](https://github.com/drodil), [Jyrki Koistinen](https://github.com/snyvision) | We are using Backstage as a gateway into our internal development platform offering to simplify complexity. | | [Scania](https://www.scania.com) | [Santosh Rangavajjula](https://linkedin.com/in/rsbth) | We are implementing backstage at Scania to consolidate operational information from Gitlab, Jira, Confluence, Artifactory, Servicenow and other tools into one place. | | [Senora.dev](https://senora.dev) | [Shaked Braimok Yosef](https://github.com/ShakedBraimok) | We are using Backstage as a service catalog for our costumers. | +| [Covestro](https://www.covestro.com) | [Julien Gedeon](https://github.com/cvvcv1) | We use Backstage as our Internal developer portal for the Digital R&D department. | diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index ee88e45305..1d6de30ce6 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -27,6 +27,45 @@ any configuration. They generate self-signed tokens automatically for making requests to other Backstage backend plugins, and the receivers use the caller's public key set endpoint to be able to perform verification. +A backend plugin wishing to make a request to another backend plugin acquires +the required token as follows, where `auth` and `httpAuth` are assumed to be +injected from `coreServices.auth` and `coreServices.httpAuth`, respectively: + +```ts +const credentials = await httpAuth.credentials(req); +const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: '', // e.g. 'catalog' +}); +``` + +In this example we are assuming that we are in an Express request handler, and +we extract the caller credentials (typically a user or a service) out of its +`req` and make the upstream request on-behalf-of that principal. Prefer to use +this pattern wherever there's an incoming set of credentials to refer to. + +If you want to initiate a request entirely as your own service, not on behalf of +anybody else, you can do so as follows: + +```ts +const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await auth.getOwnServiceCredentials(), + targetPluginId: '', // e.g. 'catalog' +}); +``` + +Callers pass along the tokens verbatim with requests in the `Authorization` +header: + +```yaml +Authorization: Bearer eyJhbG... +``` + +You may occasionally also see some code, e.g. clients to other systems, that +accept a `credentials` argument directly instead of a token. For those, just +pass in the credentials as acquired above, instead of making a token. The client +code will know what to do with those credentials internally. + This flow has only one configuration option to set in your app-config: `backend.auth.dangerouslyDisableDefaultAuthPolicy`, which can be set to `true` if you for some reason need to completely disable both the issuing and @@ -73,8 +112,8 @@ The subjects must be strings without whitespace. They are used for identifying each caller, and become part of the credentials object that request recipient plugins get. -Callers pass along the tokens verbatim with requests in the `Authorization` -header: +Callers must pass along tokens verbatim with requests in the `Authorization` +header when calling Backstage plugins: ```yaml Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW @@ -125,6 +164,13 @@ credentials object that the request recipient plugins get. All subjects will hav `external:`, but you can also provide a custom subjectPrefix which will get appended before the subject returned from your JWKS service (ex. `external:custom-prefix:sub`). +Callers must pass along tokens with requests in the `Authorization` header when +calling Backstage plugins: + +```yaml +Authorization: Bearer eyJhbG... +``` + ## Legacy Tokens Plugins and backends that are _not_ on the new backend system use a legacy token diff --git a/docs/backend-system/architecture/01-index.md b/docs/backend-system/architecture/01-index.md index 681d0fada2..968ff2f001 100644 --- a/docs/backend-system/architecture/01-index.md +++ b/docs/backend-system/architecture/01-index.md @@ -19,7 +19,11 @@ The diagram below provides an overview of the different building blocks, and the ![backend system building blocks diagram](../../assets/backend-system/architecture-building-blocks.drawio.svg) -> NOTE: These are all concepts that existed in our old backend system in one way or another, but they have now all been lifted up to be first class concerns. +:::note Note + +These are all concepts that existed in our old backend system in one way or another, but they have now all been lifted up to be first class concerns. + +::: ### Backend diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md index 93326eb0d9..777eddd3c4 100644 --- a/docs/backend-system/architecture/03-services.md +++ b/docs/backend-system/architecture/03-services.md @@ -192,7 +192,11 @@ When defining a default factory for a service, it is possible for it to end up w ## Service Factory Options -> NOTE: This pattern is discouraged, only use it when necessary. If possible you should prefer to make services configurable via static configuration instead. +:::note Note + +This pattern is discouraged, only use it when necessary. If possible you should prefer to make services configurable via static configuration instead. + +::: When declaring a service factory it's possible to include an options callback. This allows you to customize the factory through code when installing it in the backend. For example, this is how you install an explicit factory instance in the backend without any options: diff --git a/docs/backend-system/building-plugins-and-modules/01-index.md b/docs/backend-system/building-plugins-and-modules/01-index.md index 1aa8b140ed..379ce19587 100644 --- a/docs/backend-system/building-plugins-and-modules/01-index.md +++ b/docs/backend-system/building-plugins-and-modules/01-index.md @@ -6,8 +6,12 @@ sidebar_label: Overview description: Building backend plugins and modules using the new backend system --- -> NOTE: If you have an existing backend and/or backend plugins that are not yet -> using the new backend system, see [migrating](./08-migrating.md). +:::note Note + +If you have an existing backend and/or backend plugins that are not yet +using the new backend system, see [migrating](./08-migrating.md). + +::: This section covers how to build your own backend [plugins](../architecture/04-plugins.md) and [modules](../architecture/06-modules.md). They are sometimes collectively referred to as diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index fa29918311..26c9503c6d 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -10,7 +10,11 @@ description: Testing plugins in the frontend system # Testing Frontend Plugins -> NOTE: The new frontend system is in alpha, and some plugins do not yet fully implement it. +:::note Note + +The new frontend system is in alpha, and some plugins do not yet fully implement it. + +::: Utilities for testing frontend features and components are available in `@backstage/frontend-test-utils`. diff --git a/microsite/data/plugins/infrawallet.yaml b/microsite/data/plugins/infrawallet.yaml new file mode 100644 index 0000000000..bb6c3de7cf --- /dev/null +++ b/microsite/data/plugins/infrawallet.yaml @@ -0,0 +1,14 @@ +--- +title: InfraWallet +author: Electrolux Group +authorUrl: https://opensource.electrolux.one/ +category: FinOps +description: Control your cloud costs just in the way how you control your bank accounts. +documentation: https://github.com/electrolux-oss/infrawallet/blob/main/README.md +iconUrl: /img/infrawallet-logo.png +npmPackageName: '@electrolux-oss/plugin-infrawallet' +tags: + - finops + - cost + - infrastructure +addedDate: '2024-06-03' diff --git a/microsite/static/img/infrawallet-logo.png b/microsite/static/img/infrawallet-logo.png new file mode 100644 index 0000000000..d6bdbe6448 Binary files /dev/null and b/microsite/static/img/infrawallet-logo.png differ diff --git a/packages/core-components/src/components/Select/Select.tsx b/packages/core-components/src/components/Select/Select.tsx index b1a2139b85..924b49f640 100644 --- a/packages/core-components/src/components/Select/Select.tsx +++ b/packages/core-components/src/components/Select/Select.tsx @@ -78,7 +78,6 @@ const useStyles = makeStyles( createStyles({ formControl: { margin: theme.spacing(1, 0), - maxWidth: 300, }, label: { transform: 'initial', diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index d0c2467ca9..7841b5d4e6 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -37,7 +37,7 @@ "@spotify/prettier-config": "^12.0.0", "concurrently": "^8.0.0", "lerna": "^7.3.0", - "node-gyp": "^9.0.0", + "node-gyp": "^10.0.0", "prettier": "^2.3.2", "typescript": "~5.4.0" }, diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 93228e5c8f..049ea1c30c 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -41,7 +41,7 @@ "app": "link:../app", "better-sqlite3": "^9.0.0", "dockerode": "^3.3.1", - "node-gyp": "^9.0.0", + "node-gyp": "^10.0.0", "pg": "^8.11.3", "winston": "^3.2.1" }, diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts index 4f1db097d0..67bfd27b19 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -493,5 +493,52 @@ describe('oidcAuthenticator', () => { new Error('Refresh failed'), ); }); + + it('should not revoke refreshToken when issuer revocation_endpoint is undefined', async () => { + const refreshToken = 'revokeRefreshToken2'; + const refreshRequest = { + scope: 'testScope', + refreshToken, + req: {} as express.Request, + }; + const logoutRequest = { + refreshToken, + req: {} as express.Request, + }; + + // override .well-known endpoint response, set revocation_endpoint to undefined + mswServer.use( + rest.get( + 'https://oidc.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + ...issuerMetadata, + revocation_endpoint: undefined, + }), + ), + ), + ); + + const newImplementation = oidcAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + + await oidcAuthenticator.logout?.(logoutRequest, newImplementation); + + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + newImplementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); }); }); diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index f954d228e2..2b6a9b34f1 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -202,8 +202,15 @@ export const oidcAuthenticator = createOAuthAuthenticator({ async logout(input, ctx) { const { client } = await ctx.promise; + const issuer = client.issuer; + /** + * https://github.com/panva/node-openid-client/blob/main/lib/client.js#L1398 + * client.revoke will check revocation_endpoint and if undefined throw error。 + * + * if oidc server do not provide revocation_endpoint,we should not call revoke function + */ - if (input.refreshToken) { + if (input.refreshToken && issuer.revocation_endpoint) { await client.revoke(input.refreshToken); } }, diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1dbe7ad7b0..3b86c58e34 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -70,6 +70,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", + "@microsoft/fetch-event-source": "^2.0.1", "@react-hookz/web": "^24.0.0", "@rjsf/core": "5.18.2", "@rjsf/material-ui": "5.18.2", @@ -78,7 +79,6 @@ "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", "@uiw/react-codemirror": "^4.9.3", "classnames": "^2.2.6", - "event-source-polyfill": "^1.0.31", "git-url-parse": "^14.0.0", "humanize-duration": "^3.25.1", "json-schema": "^0.4.0", diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index b9b1881066..488169719d 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -20,14 +20,13 @@ import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ScaffolderClient } from './api'; -import { EventSourcePolyfill } from 'event-source-polyfill'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; -const MockedEventSource = EventSourcePolyfill as jest.MockedClass< - typeof EventSourcePolyfill +jest.mock('@microsoft/fetch-event-source'); +const mockFetchEventSource = fetchEventSource as jest.MockedFunction< + typeof fetchEventSource >; -jest.mock('event-source-polyfill'); - const server = setupServer(); describe('api', () => { @@ -87,26 +86,23 @@ describe('api', () => { describe('streamEvents', () => { describe('eventsource', () => { it('should work', async () => { - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (typeof fn !== 'function') { - return; - } - - if (type === 'log') { - fn({ - data: '{"id":1,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}}', - } as any); - } else if (type === 'completion') { - fn({ - data: '{"id":2,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}}', - } as any); - } - }, - ); - - const token = 'fake-token'; - identityApi.getCredentials.mockResolvedValue({ token: token }); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onopen, onmessage } = options; + await Promise.resolve(); + await onopen?.({ ok: true } as Response); + await Promise.resolve(); + onmessage?.({ + id: '', + event: 'log', + data: '{"id":1,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}}', + }); + await Promise.resolve(); + onmessage?.({ + id: '', + event: 'completion', + data: '{"id":2,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}}', + }); + }); const next = jest.fn(); @@ -116,14 +112,15 @@ describe('api', () => { .subscribe({ next, complete }); }); - expect(MockedEventSource).toHaveBeenCalledWith( + expect(mockFetchEventSource).toHaveBeenCalledWith( 'http://backstage/api/v2/tasks/a-random-task-id/eventstream', { - withCredentials: true, - headers: { Authorization: `Bearer ${token}` }, + fetch: fetchApi.fetch, + onmessage: expect.any(Function), + onerror: expect.any(Function), + signal: expect.any(AbortSignal), }, ); - expect(MockedEventSource.prototype.close).toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(2); expect(next).toHaveBeenCalledWith({ diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index fe0d7c11d0..6ef5fff588 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -41,7 +41,10 @@ import { } from '@backstage/plugin-scaffolder-react'; import queryString from 'qs'; -import { EventSourcePolyfill } from 'event-source-polyfill'; +import { + EventSourceMessage, + fetchEventSource, +} from '@microsoft/fetch-event-source'; /** * An API to interact with the scaffolder backend. @@ -226,11 +229,8 @@ export class ScaffolderClient implements ScaffolderApi { params.set('after', String(Number(after))); } - Promise.all([ - this.discoveryApi.getBaseUrl('scaffolder'), - this.identityApi?.getCredentials(), - ]).then( - ([baseUrl, credentials]) => { + this.discoveryApi.getBaseUrl('scaffolder').then( + baseUrl => { const url = `${baseUrl}/v2/tasks/${encodeURIComponent( taskId, )}/eventstream`; @@ -245,22 +245,25 @@ export class ScaffolderClient implements ScaffolderApi { } }; - const eventSource = new EventSourcePolyfill(url, { - withCredentials: true, - headers: credentials?.token - ? { Authorization: `Bearer ${credentials.token}` } - : {}, - }); - eventSource.addEventListener('log', processEvent); - eventSource.addEventListener('recovered', processEvent); - eventSource.addEventListener('cancelled', processEvent); - eventSource.addEventListener('completion', (event: any) => { - processEvent(event); - eventSource.close(); - subscriber.complete(); - }); - eventSource.addEventListener('error', event => { - subscriber.error(event); + const ctrl = new AbortController(); + fetchEventSource(url, { + fetch: this.fetchApi.fetch, + signal: ctrl.signal, + onmessage(e: EventSourceMessage) { + if (e.event === 'log') { + processEvent(e); + return; + } else if (e.event === 'completion') { + processEvent(e); + subscriber.complete(); + ctrl.abort(); + return; + } + processEvent(e); + }, + onerror(err) { + subscriber.error(err); + }, }); }, error => { diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 8608e5b98e..7b25d3706a 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -16,9 +16,6 @@ import '@testing-library/jest-dom'; -const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill'); -global.EventSource = EventSourcePolyfill; - // Patch jsdom to add feature used by CodeMirror document.createRange = () => { const range = new Range(); diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index ff43f4309b..ebd6adcd3c 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -465,8 +465,8 @@ export class TechDocsStorageClient implements TechDocsStorageApi_2 { constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; - identityApi: IdentityApi; fetchApi: FetchApi; + identityApi?: IdentityApi; }); // (undocumented) configApi: Config; @@ -485,8 +485,6 @@ export class TechDocsStorageClient implements TechDocsStorageApi_2 { getEntityDocs(entityId: CompoundEntityRef, path: string): Promise; // (undocumented) getStorageUrl(): Promise; - // (undocumented) - identityApi: IdentityApi; syncEntityDocs( entityId: CompoundEntityRef, logHandler?: (line: string) => void, diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 9cf0364c63..57ed36c4e3 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -25,11 +25,7 @@ import { techdocsStorageApiRef, } from '../src'; -import { - configApiRef, - discoveryApiRef, - identityApiRef, -} from '@backstage/core-plugin-api'; +import { configApiRef, discoveryApiRef } from '@backstage/core-plugin-api'; import { TechDocsReaderPageProvider } from '@backstage/plugin-techdocs-react'; import { Header, Page, TabbedLayout } from '@backstage/core-components'; @@ -135,7 +131,6 @@ createDevApp() deps: { configApi: configApiRef, discoveryApi: discoveryApiRef, - identityApi: identityApiRef, }, factory: () => apiBridge, }) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 845b65cd8f..c0113c0c43 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -70,9 +70,9 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@material-ui/styles": "^4.10.0", + "@microsoft/fetch-event-source": "^2.0.1", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", "dompurify": "^3.0.0", - "event-source-polyfill": "1.0.25", "git-url-parse": "^14.0.0", "jss": "~10.10.0", "lodash": "^4.17.21", @@ -90,7 +90,6 @@ "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^14.0.0", "@types/dompurify": "^3.0.0", - "@types/event-source-polyfill": "^1.0.0", "canvas": "^2.10.2" }, "peerDependencies": { diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index 88dce3b48e..07fd7667ff 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -29,7 +29,6 @@ import { createApiFactory, discoveryApiRef, fetchApiRef, - identityApiRef, } from '@backstage/core-plugin-api'; import { compatWrapper, @@ -55,14 +54,12 @@ const techDocsStorageApi = createApiExtension({ deps: { configApi: configApiRef, discoveryApi: discoveryApiRef, - identityApi: identityApiRef, fetchApi: fetchApiRef, }, - factory: ({ configApi, discoveryApi, identityApi, fetchApi }) => + factory: ({ configApi, discoveryApi, fetchApi }) => new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }), }), diff --git a/plugins/techdocs/src/client.test.ts b/plugins/techdocs/src/client.test.ts index dde52cda49..62d497d790 100644 --- a/plugins/techdocs/src/client.test.ts +++ b/plugins/techdocs/src/client.test.ts @@ -17,16 +17,15 @@ import { UrlPatternDiscovery } from '@backstage/core-app-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { NotFoundError } from '@backstage/errors'; -import { EventSourcePolyfill } from 'event-source-polyfill'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; import { MockConfigApi, MockFetchApi } from '@backstage/test-utils'; import { TechDocsStorageClient } from './client'; -const MockedEventSource = EventSourcePolyfill as jest.MockedClass< - typeof EventSourcePolyfill +jest.mock('@microsoft/fetch-event-source'); +const mockFetchEventSource = fetchEventSource as jest.MockedFunction< + typeof fetchEventSource >; -jest.mock('event-source-polyfill'); - const mockEntity = { kind: 'Component', namespace: 'default', @@ -51,7 +50,6 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); @@ -72,7 +70,6 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); @@ -84,71 +81,50 @@ describe('TechDocsStorageClient', () => { }); describe('syncEntityDocs', () => { - it('should create eventsource without headers', async () => { + it('should create eventsource with fetch', async () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (type === 'finish' && typeof fn === 'function') { - fn({ data: '{"updated": false}' } as any); - } - }, - ); - + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onopen, onmessage } = options; + await Promise.resolve(); + await onopen?.({ ok: true } as Response); + await Promise.resolve(); + onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' }); + }); identityApi.getCredentials.mockResolvedValue({}); await storageApi.syncEntityDocs(mockEntity); - expect(MockedEventSource).toHaveBeenCalledWith( + expect(mockFetchEventSource).toHaveBeenCalledWith( 'http://backstage:9191/api/techdocs/sync/default/Component/test-component', - { withCredentials: true, headers: {} }, - ); - }); - - it('should create eventsource with headers', async () => { - const storageApi = new TechDocsStorageClient({ - configApi, - discoveryApi, - identityApi, - fetchApi, - }); - - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (type === 'finish' && typeof fn === 'function') { - fn({ data: '{"updated": false}' } as any); - } + { + fetch: fetchApi.fetch, + onerror: expect.any(Function), + onmessage: expect.any(Function), + signal: expect.any(AbortSignal), }, ); - - identityApi.getCredentials.mockResolvedValue({ token: 'token' }); - await storageApi.syncEntityDocs(mockEntity); - - expect(MockedEventSource).toHaveBeenCalledWith( - 'http://backstage:9191/api/techdocs/sync/default/Component/test-component', - { withCredentials: true, headers: { Authorization: 'Bearer token' } }, - ); }); it('should resolve to cached', async () => { + const myFetchApi = new MockFetchApi({ + injectIdentityAuth: { identityApi }, + }); const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, - fetchApi, + fetchApi: myFetchApi, + }); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onopen, onmessage } = options; + await Promise.resolve(); + await onopen?.({ ok: true } as Response); + await Promise.resolve(); + onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' }); }); - - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (type === 'finish' && typeof fn === 'function') { - fn({ data: '{"updated": false}' } as any); - } - }, - ); identityApi.getCredentials.mockResolvedValue({}); await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual( @@ -160,17 +136,15 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); - - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (type === 'finish' && typeof fn === 'function') { - fn({ data: '{"updated": true}' } as any); - } - }, - ); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onopen, onmessage } = options; + await Promise.resolve(); + await onopen?.({ ok: true } as Response); + await Promise.resolve(); + onmessage?.({ id: '', event: 'finish', data: '{"updated": true}' }); + }); identityApi.getCredentials.mockResolvedValue({}); await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual( @@ -182,21 +156,18 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); - MockedEventSource.prototype.addEventListener.mockImplementation( - (type, fn) => { - if (type === 'log' && typeof fn === 'function') { - fn({ data: '"A log message"' } as any); - } - - if (type === 'finish' && typeof fn === 'function') { - fn({ data: '{"updated": false}' } as any); - } - }, - ); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onopen, onmessage } = options; + await Promise.resolve(); + await onopen?.({ ok: true } as Response); + await Promise.resolve(); + onmessage?.({ id: '', event: 'log', data: '"A log message"' }); + await Promise.resolve(); + onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' }); + }); identityApi.getCredentials.mockResolvedValue({}); const logHandler = jest.fn(); @@ -212,25 +183,18 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onerror } = options; + onerror?.(new NotFoundError('Some not found warning')); + }); + // we await later after we emitted the error identityApi.getCredentials.mockResolvedValue({}); const promise = storageApi.syncEntityDocs(mockEntity).then(); - // flush the event loop - await new Promise(r => setTimeout(r)); - - const instance = MockedEventSource.mock - .instances[0] as jest.Mocked; - - instance.onerror?.({ - status: 404, - message: 'Some not found warning', - } as any); - await expect(promise).rejects.toThrow(NotFoundError); await expect(promise).rejects.toThrow('Some not found warning'); }); @@ -239,7 +203,6 @@ describe('TechDocsStorageClient', () => { const storageApi = new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }); @@ -247,16 +210,10 @@ describe('TechDocsStorageClient', () => { identityApi.getCredentials.mockResolvedValue({}); const promise = storageApi.syncEntityDocs(mockEntity).then(); - // flush the event loop - await new Promise(r => setTimeout(r)); - - const instance = MockedEventSource.mock - .instances[0] as jest.Mocked; - - instance.onerror?.({ - type: 'error', - data: 'Some other error', - } as any); + mockFetchEventSource.mockImplementation(async (_url, options) => { + const { onerror } = options; + onerror?.(new Error('Some other error')); + }); await expect(promise).rejects.toThrow(Error); await expect(promise).rejects.toThrow('Some other error'); diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 0f11eb4068..d00be2a01f 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -29,7 +29,7 @@ import { TechDocsMetadata, TechDocsStorageApi, } from '@backstage/plugin-techdocs-react'; -import { EventSourcePolyfill } from 'event-source-polyfill'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; /** * API to talk to `techdocs-backend`. @@ -124,18 +124,17 @@ export class TechDocsClient implements TechDocsApi { export class TechDocsStorageClient implements TechDocsStorageApi { public configApi: Config; public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; private fetchApi: FetchApi; constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; - identityApi: IdentityApi; fetchApi: FetchApi; + /** @deprecated identityApi is not needed any more */ + identityApi?: IdentityApi; }) { this.configApi = options.configApi; this.discoveryApi = options.discoveryApi; - this.identityApi = options.identityApi; this.fetchApi = options.fetchApi; } @@ -213,47 +212,32 @@ export class TechDocsStorageClient implements TechDocsStorageApi { const apiOrigin = await this.getApiOrigin(); const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; - const { token } = await this.identityApi.getCredentials(); return new Promise((resolve, reject) => { - // Polyfill is used to add support for custom headers and auth - const source = new EventSourcePolyfill(url, { - withCredentials: true, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - - source.addEventListener('log', (e: any) => { - if (e.data) { - logHandler(JSON.parse(e.data)); - } - }); - - source.addEventListener('finish', (e: any) => { - let updated: boolean = false; - - if (e.data) { - ({ updated } = JSON.parse(e.data)); - } - - resolve(updated ? 'updated' : 'cached'); - }); - - source.onerror = (e: any) => { - source.close(); - - switch (e.status) { - // the endpoint returned a 404 status - case 404: - reject(new NotFoundError(e.message)); - return; - - // also handles the event-stream close. the reject is ignored if the Promise was already - // resolved by a finish event. - default: + const ctrl = new AbortController(); + fetchEventSource(url, { + fetch: this.fetchApi.fetch, + signal: ctrl.signal, + onmessage(e: any) { + if (e.event === 'log') { + if (e.data) { + logHandler(JSON.parse(e.data)); + } + } else if (e.event === 'finish') { + let updated: boolean = false; + if (e.data) { + ({ updated } = JSON.parse(e.data)); + } + resolve(updated ? 'updated' : 'cached'); + } else if (e.event === 'error') { reject(new Error(e.data)); - return; - } - }; + } + }, + onerror(err) { + ctrl.abort(); + reject(err); + }, + }); }); } diff --git a/plugins/techdocs/src/event-source-polyfill.d.ts b/plugins/techdocs/src/event-source-polyfill.d.ts deleted file mode 100644 index 8e298bde38..0000000000 --- a/plugins/techdocs/src/event-source-polyfill.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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. - */ - -declare module 'event-source-polyfill' { - export class EventSourcePolyfill extends EventSource { - constructor( - url: string, - options?: { - withCredentials?: boolean; - headers?: HeadersInit; - }, - ); - } -} diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index bdd4814898..efa6bdfc1f 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -31,7 +31,6 @@ import { createRoutableExtension, discoveryApiRef, fetchApiRef, - identityApiRef, } from '@backstage/core-plugin-api'; import { createSearchResultListItemExtension, @@ -52,14 +51,12 @@ export const techdocsPlugin = createPlugin({ deps: { configApi: configApiRef, discoveryApi: discoveryApiRef, - identityApi: identityApiRef, fetchApi: fetchApiRef, }, - factory: ({ configApi, discoveryApi, identityApi, fetchApi }) => + factory: ({ configApi, discoveryApi, fetchApi }) => new TechDocsStorageClient({ configApi, discoveryApi, - identityApi, fetchApi, }), }), diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index e2aaf8c33a..963bc40c9c 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -36,11 +36,11 @@ const depTypes = [ const roleRules = [ { sourceRole: ['frontend-plugin', 'web-library'], - targetRole: ['backend-plugin', 'node-library'], + targetRole: ['backend-plugin', 'node-library', 'backend-plugin-module'], message: `Package SOURCE_NAME with frontend role SOURCE_ROLE has a dependency on package TARGET_NAME with backend role TARGET_ROLE, which is not permitted`, }, { - sourceRole: ['backend-plugin', 'node-library'], + sourceRole: ['backend-plugin', 'node-library', 'backend-plugin-module'], targetRole: ['frontend-plugin', 'web-library'], message: `Package SOURCE_NAME with backend role SOURCE_ROLE has a dependency on package TARGET_NAME with frontend role TARGET_ROLE, which is not permitted`, }, @@ -51,8 +51,9 @@ const roleRules = [ 'web-library', 'backend-plugin', 'node-library', + 'backend-plugin-module', ], - message: `Polymorphic package SOURCE_NAME has a dependency on package TARGET_NAME with role TARGET_ROLE, which is not permitted since it's not also polymorphic`, + message: `Polymorphic package SOURCE_NAME with role SOURCE_ROLE has a dependency on package TARGET_NAME with role TARGET_ROLE, which is not permitted since it's not also polymorphic`, }, { sourceRole: ['frontend-plugin', 'web-library'], @@ -62,7 +63,7 @@ const roleRules = [ '@backstage/plugin-api-docs', '@backstage/plugin-techdocs-addons-test-utils', ], - message: `Package SOURCE_NAME with role SOURCE_ROLE has a dependency on another plugin package TARGET_NAME, which is not permitted`, + message: `Package SOURCE_NAME with role SOURCE_ROLE has a dependency on another plugin package TARGET_NAME with role TARGET_ROLE, which is not permitted`, }, { sourceRole: ['frontend-plugin', 'web-library'], @@ -80,7 +81,7 @@ const roleRules = [ '@backstage/plugin-techdocs-addons-test-utils', '@backstage/plugin-user-settings', ], - message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`, + message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME with role TARGET_ROLE, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`, }, { sourceRole: ['backend-plugin', 'node-library'], @@ -92,7 +93,7 @@ const roleRules = [ '@backstage/backend-test-utils', '@backstage/backend-dynamic-feature-service', ], - message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`, + message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME with role TARGET_ROLE, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`, }, ]; diff --git a/yarn.lock b/yarn.lock index 66394ddf48..62189db390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6979,6 +6979,7 @@ __metadata: "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 + "@microsoft/fetch-event-source": ^2.0.1 "@react-hookz/web": ^24.0.0 "@rjsf/core": 5.18.2 "@rjsf/material-ui": 5.18.2 @@ -6993,7 +6994,6 @@ __metadata: "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 "@uiw/react-codemirror": ^4.9.3 classnames: ^2.2.6 - event-source-polyfill: ^1.0.31 git-url-parse: ^14.0.0 humanize-duration: ^3.25.1 json-schema: ^0.4.0 @@ -7557,16 +7557,15 @@ __metadata: "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 "@material-ui/styles": ^4.10.0 + "@microsoft/fetch-event-source": ^2.0.1 "@testing-library/dom": ^10.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^15.0.0 "@testing-library/user-event": ^14.0.0 "@types/dompurify": ^3.0.0 - "@types/event-source-polyfill": ^1.0.0 "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 canvas: ^2.10.2 dompurify: ^3.0.0 - event-source-polyfill: 1.0.25 git-url-parse: ^14.0.0 jss: ~10.10.0 lodash: ^4.17.21 @@ -10529,6 +10528,13 @@ __metadata: languageName: node linkType: hard +"@microsoft/fetch-event-source@npm:^2.0.1": + version: 2.0.1 + resolution: "@microsoft/fetch-event-source@npm:2.0.1" + checksum: a50e1c0f33220206967266d0a4bbba0703e2793b079d9f6e6bfd48f71b2115964a803e14cf6e902c6fab321edc084f26022334f5eaacc2cec87f174715d41852 + languageName: node + linkType: hard + "@microsoft/microsoft-graph-types@npm:^2.6.0": version: 2.40.0 resolution: "@microsoft/microsoft-graph-types@npm:2.40.0" @@ -14253,8 +14259,8 @@ __metadata: linkType: hard "@rollup/plugin-commonjs@npm:^25.0.0": - version: 25.0.7 - resolution: "@rollup/plugin-commonjs@npm:25.0.7" + version: 25.0.8 + resolution: "@rollup/plugin-commonjs@npm:25.0.8" dependencies: "@rollup/pluginutils": ^5.0.1 commondir: ^1.0.1 @@ -14267,7 +14273,7 @@ __metadata: peerDependenciesMeta: rollup: optional: true - checksum: 052e11839a9edc556eda5dcc759ab816dcc57e9f0f905a1e6e14fff954eaa6b1e2d0d544f5bd18d863993c5eba43d8ac9c19d9bb53b1c3b1213f32cfc9d50b2e + checksum: dd105ee5625fbcaf832c0cf80be0aaf6a86bbd8fe99ff911f9ac4b78c79f26e9e99442b5aa0cc1136b5ddf89ec0b6c5728e5341ac04d687aef1b53063670b395 languageName: node linkType: hard @@ -17149,13 +17155,6 @@ __metadata: languageName: node linkType: hard -"@types/event-source-polyfill@npm:^1.0.0": - version: 1.0.5 - resolution: "@types/event-source-polyfill@npm:1.0.5" - checksum: f506b68710162f2ade1bccbc5691b8c67e5a703e565df2bc0b7b5be2637ba838ef81ec6c10b03248fe4d054386d95a6e827c7aace6e924986c2b9985f77b55de - languageName: node - linkType: hard - "@types/expect@npm:^1.20.4": version: 1.20.4 resolution: "@types/expect@npm:1.20.4" @@ -25522,20 +25521,6 @@ __metadata: languageName: node linkType: hard -"event-source-polyfill@npm:1.0.25": - version: 1.0.25 - resolution: "event-source-polyfill@npm:1.0.25" - checksum: ed30428cc80eadfd693d267ba4a72dceaae938174cd116081ce38ad62bfd95f199430be7e8341e6f8f1e29489bbd5cfd4b3f6c8d6d463435623f7f91ae5f71b1 - languageName: node - linkType: hard - -"event-source-polyfill@npm:^1.0.31": - version: 1.0.31 - resolution: "event-source-polyfill@npm:1.0.31" - checksum: 973f226404e2a1b14ed7ef15c718b89e213b41d7cfeeb1c10937fd09229f13904f3d7c3075ab28ccf858c213007559908eecdd577577330352f53a351383dd75 - languageName: node - linkType: hard - "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1"