Merge pull request #24861 from alexef/new-events-implementation

fix: move from event-source-polyfill to fetch-event-source
This commit is contained in:
Patrik Oldsberg
2024-06-03 15:00:29 +02:00
committed by GitHub
14 changed files with 152 additions and 264 deletions
+6
View File
@@ -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.
+1 -1
View File
@@ -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",
+26 -29
View File
@@ -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({
+25 -22
View File
@@ -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 => {
-3
View File
@@ -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();
+1 -3
View File
@@ -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<string>;
// (undocumented)
getStorageUrl(): Promise<string>;
// (undocumented)
identityApi: IdentityApi;
syncEntityDocs(
entityId: CompoundEntityRef,
logHandler?: (line: string) => void,
+1 -6
View File
@@ -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,
})
+1 -2
View File
@@ -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": {
+1 -4
View File
@@ -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,
}),
}),
+54 -97
View File
@@ -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<EventSource>;
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<EventSource>;
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');
+26 -42
View File
@@ -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);
},
});
});
}
-27
View File
@@ -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;
},
);
}
}
+1 -4
View File
@@ -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,
}),
}),
+9 -24
View File
@@ -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"
@@ -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"