diff --git a/.changeset/cyan-trees-bow.md b/.changeset/cyan-trees-bow.md
new file mode 100644
index 0000000000..6f23f67f55
--- /dev/null
+++ b/.changeset/cyan-trees-bow.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-events-node': patch
+---
+
+Fixed an issue where subscribing to events threw an error and gave up too easily. Calling the subscribe method will cause the background polling loop to keep trying to connect to the events backend, even if the initial request fails.
+
+By default the events service will attempt to publish and subscribe to events from the events bus API in the events backend, but if it fails due to the events backend not being installed, it will bail and never try calling the API again. There is now a new `events.useEventBus` configuration and option for the `DefaultEventsService` that lets you control this behavior. You can set it to `'never'` to disabled API calls to the events backend completely, or `'always'` to never allow it to be disabled.
diff --git a/.changeset/green-tools-arrive.md b/.changeset/green-tools-arrive.md
new file mode 100644
index 0000000000..78eed180d4
--- /dev/null
+++ b/.changeset/green-tools-arrive.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-react': patch
+---
+
+Fix issue with form state not refreshing when updating
diff --git a/.changeset/red-fishes-press.md b/.changeset/red-fishes-press.md
new file mode 100644
index 0000000000..8493ddf446
--- /dev/null
+++ b/.changeset/red-fishes-press.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Added DomPurify sanitizer configuration for custom elements implementing RFC https://github.com/backstage/backstage/issues/26988.
+See https://backstage.io/docs/features/techdocs/how-to-guides#how-to-enable-custom-elements-in-techdocs for how to enable it in the configuration.
diff --git a/.changeset/silent-wasps-whisper.md b/.changeset/silent-wasps-whisper.md
new file mode 100644
index 0000000000..33620139e7
--- /dev/null
+++ b/.changeset/silent-wasps-whisper.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': patch
+---
+
+Revert the change of the option label for `EntityPicker`
diff --git a/.changeset/spotty-snails-worry.md b/.changeset/spotty-snails-worry.md
new file mode 100644
index 0000000000..3ecf2e9b35
--- /dev/null
+++ b/.changeset/spotty-snails-worry.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-signals-backend': patch
+---
+
+The signals backend now supports scaled deployments where clients may be connecting to one of many signal backend instances.
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 37f54997c6..77faa9354f 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -40,6 +40,11 @@
matchSourceUrls: ['https://github.com/yarnpkg/berry'],
enabled: false,
},
+ // https://github.com/backstage/backstage/issues/27123
+ {
+ matchPackageNames: ['browser-actions/setup-chrome'],
+ enabled: false,
+ },
// ESM only majors, that we're not ready for yet
{
matchPackageNames: ['node-fetch'],
diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml
index 3f7b2e4a5b..a3333b9bb1 100644
--- a/.github/workflows/verify_storybook.yml
+++ b/.github/workflows/verify_storybook.yml
@@ -51,7 +51,7 @@ jobs:
- run: yarn build-storybook
- - uses: chromaui/action@bbbf288765438d5fd2be13e1d80d542a39e74108 # v11
+ - uses: chromaui/action@1b04e0322aba2604aa23c6714aa6f704bdb301ad # v11
with:
token: ${{ secrets.GITHUB_TOKEN }}
# projectToken intentionally shared to allow collaborators to run Chromatic on forks
diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md
index fd4d208b74..f8be6c9dd5 100644
--- a/docs/features/techdocs/how-to-guides.md
+++ b/docs/features/techdocs/how-to-guides.md
@@ -545,6 +545,26 @@ techdocs:
This way, all iframes where the host in the src attribute is in the
`sanitizer.allowedIframeHosts` list will be displayed.
+## How to enable custom elements in TechDocs
+
+TechDocs uses the [DOMPurify](https://github.com/cure53/DOMPurify) library to
+sanitize HTML and prevent XSS attacks.
+
+It's possible to allow custom elements based on a list of allowed patterns. To do
+this, add the allowed elements and attributes in the `techdocs.sanitizer.allowedCustomElementTagNameRegExp`
+and `allowedCustomElementAttributeNameRegExp` configuration of your `app-config.yaml`.
+
+For example:
+
+```yaml
+techdocs:
+ sanitizer:
+ allowedCustomElementTagNameRegExp: '^backstage-',
+ allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2',
+```
+
+This way, custom element like `` will be allowed in the result HTML.
+
## How to render PlantUML diagram in TechDocs
PlantUML allows you to create diagrams from plain text language. Each diagram description begins with the keyword - (@startXYZ and @endXYZ, depending on the kind of diagram). For UML Diagrams, Keywords @startuml & @enduml should be used. Further details for all types of diagrams can be found at [PlantUML Language Reference Guide](https://plantuml.com/guide).
diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md
index a936a0f399..43636c8a0e 100644
--- a/docs/plugins/analytics.md
+++ b/docs/plugins/analytics.md
@@ -55,6 +55,7 @@ learn how to contribute the integration yourself!
[add-tool]: https://github.com/backstage/backstage/issues/new?assignees=&labels=plugin&template=plugin_template.md&title=%5BAnalytics+Module%5D+THE+ANALYTICS+TOOL+TO+INTEGRATE
[int-howto]: #writing-integrations
[analytics-api-type]: https://backstage.io/docs/reference/core-plugin-api.analyticsapi
+[generic-http]: https://github.com/pfeifferj/backstage-plugin-analytics-generic/blob/main/README.md
## Key Events
diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx
index 72480cc72a..51fcfee6c4 100644
--- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx
+++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx
@@ -24,7 +24,6 @@ import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils';
import { catalogApiRef } from '../../api';
import {
CompoundEntityRef,
- Entity,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
@@ -33,45 +32,40 @@ import { Table, TableColumn } from '@backstage/core-components';
import { EntityRefLink } from '../EntityRefLink';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
-const mockCatalogApi = catalogApiMock.mock({
- getEntityByRef: async (entityRef: string) => {
- if (entityRef === 'component:default/playback') {
- return {
- kind: 'Component',
- metadata: {
- name: 'playback',
- namespace: 'default',
- description: 'Details about the playback service',
+const mockCatalogApi = catalogApiMock({
+ entities: [
+ {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'playback',
+ namespace: 'default',
+ description: 'Details about the playback service',
+ },
+ },
+ {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: {
+ name: 'fname.lname',
+ namespace: 'default',
+ },
+ spec: {
+ profile: {
+ email: 'fname.lname@example.com',
},
- } as unknown as Entity;
- }
- if (entityRef === 'user:default/fname.lname') {
- return {
- kind: 'User',
- metadata: {
- name: 'fname.lname',
- namespace: 'default',
- },
- spec: {
- profile: {
- email: 'fname.lname@example.com',
- },
- },
- } as unknown as Entity;
- }
- if (entityRef === 'component:default/slow.catalog.item') {
- await new Promise(resolve => setTimeout(resolve, 3000));
- return {
- kind: 'Component',
- metadata: {
- name: 'slow.catalog.item',
- namespace: 'default',
- description: 'Details about the slow.catalog.item service',
- },
- } as unknown as Entity;
- }
- return undefined;
- },
+ },
+ },
+ {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'slow.catalog.item',
+ namespace: 'default',
+ description: 'Details about the slow.catalog.item service',
+ },
+ },
+ ],
});
const defaultArgs = {
diff --git a/plugins/events-node/config.d.ts b/plugins/events-node/config.d.ts
new file mode 100644
index 0000000000..4a49230261
--- /dev/null
+++ b/plugins/events-node/config.d.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 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.
+ */
+
+export interface Config {
+ events?: {
+ /**
+ * Whether to use the event bus API in the events plugin backend to
+ * distribute events across multiple instances when publishing and
+ * subscribing to events.
+ *
+ * The default is 'auto', which means means that the event bus API will be
+ * used if it's available, but will be disabled if the events backend
+ * returns a 404.
+ *
+ * If set to 'never', the events service will only ever publish events
+ * locally to the same instance, while if set to 'always', the event bus API
+ * will never be disabled, even if the events backend returns a 404.
+ */
+ useEventBus?: 'never' | 'always' | 'auto';
+ };
+}
diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json
index 316cc497f8..f9e5ee1ff6 100644
--- a/plugins/events-node/package.json
+++ b/plugins/events-node/package.json
@@ -39,7 +39,8 @@
}
},
"files": [
- "dist"
+ "dist",
+ "config.d.ts"
],
"scripts": {
"build": "backstage-cli package build",
@@ -60,6 +61,8 @@
"devDependencies": {
"@backstage/backend-common": "^0.25.0",
"@backstage/backend-test-utils": "workspace:^",
- "@backstage/cli": "workspace:^"
- }
+ "@backstage/cli": "workspace:^",
+ "msw": "^1.0.0"
+ },
+ "configSchema": "config.d.ts"
}
diff --git a/plugins/events-node/report.api.md b/plugins/events-node/report.api.md
index 9ebaf1a536..a9e028e049 100644
--- a/plugins/events-node/report.api.md
+++ b/plugins/events-node/report.api.md
@@ -7,13 +7,18 @@ import { AuthService } from '@backstage/backend-plugin-api';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
+import { RootConfigService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
// @public
export class DefaultEventsService implements EventsService {
// (undocumented)
- static create(options: { logger: LoggerService }): DefaultEventsService;
+ static create(options: {
+ logger: LoggerService;
+ config?: RootConfigService;
+ useEventBus?: EventBusMode;
+ }): DefaultEventsService;
forPlugin(
pluginId: string,
options?: {
@@ -37,6 +42,9 @@ export interface EventBroker {
): void;
}
+// @public (undocumented)
+export type EventBusMode = 'never' | 'always' | 'auto';
+
// @public (undocumented)
export interface EventParams {
eventPayload: TPayload;
diff --git a/plugins/events-node/src/api/DefaultEventsService.test.ts b/plugins/events-node/src/api/DefaultEventsService.test.ts
index 6e005e9e36..d2fda67d6c 100644
--- a/plugins/events-node/src/api/DefaultEventsService.test.ts
+++ b/plugins/events-node/src/api/DefaultEventsService.test.ts
@@ -16,12 +16,16 @@
import { DefaultEventsService } from './DefaultEventsService';
import { EventParams } from './EventParams';
-import { mockServices } from '@backstage/backend-test-utils';
-
-const logger = mockServices.logger.mock();
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import {
+ mockServices,
+ registerMswTestHooks,
+} from '@backstage/backend-test-utils';
describe('DefaultEventsService', () => {
it('passes events to interested subscribers', async () => {
+ const logger = mockServices.logger.mock();
const events = DefaultEventsService.create({ logger });
const eventsSubscriber1: EventParams[] = [];
const eventsSubscriber2: EventParams[] = [];
@@ -70,6 +74,7 @@ describe('DefaultEventsService', () => {
it('logs errors from subscribers', async () => {
const topic = 'testTopic';
+ const logger = mockServices.logger.mock();
const warnSpy = jest.spyOn(logger, 'warn');
const events = DefaultEventsService.create({ logger });
@@ -108,4 +113,165 @@ describe('DefaultEventsService', () => {
new Error('NOPE 2'),
);
});
+
+ describe('with event bus', () => {
+ const mswServer = setupServer();
+ registerMswTestHooks(mswServer);
+
+ it('should read events from events bus API', async () => {
+ const logger = mockServices.logger.mock();
+ const service = DefaultEventsService.create({ logger }).forPlugin('a', {
+ auth: mockServices.auth(),
+ logger,
+ discovery: mockServices.discovery(),
+ lifecycle: mockServices.lifecycle.mock(),
+ });
+
+ mswServer.use(
+ rest.put(
+ 'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
+ (_req, res, ctx) => res(ctx.status(200)),
+ ),
+ rest.get(
+ 'http://localhost:0/api/events/bus/v1/subscriptions/a.tester/events',
+ (_req, res, ctx) =>
+ res(
+ ctx.status(200),
+ ctx.json({
+ events: [{ topic: 'test', payload: { foo: 'bar' } }],
+ }),
+ ),
+ ),
+ );
+
+ const event = await new Promise(resolve => {
+ service.subscribe({
+ id: 'tester',
+ topics: ['test'],
+ async onEvent(newEvent) {
+ resolve(newEvent);
+ },
+ });
+ });
+
+ expect(event).toEqual({ topic: 'test', eventPayload: { foo: 'bar' } });
+
+ // Internal call to clean up subscriptions
+ await (service as any).shutdown();
+ });
+
+ it('should not read events from bus if disabled', async () => {
+ const logger = mockServices.logger.mock();
+ const service = DefaultEventsService.create({
+ logger,
+ useEventBus: 'never',
+ }).forPlugin('a', {
+ auth: mockServices.auth(),
+ logger,
+ discovery: mockServices.discovery(),
+ lifecycle: mockServices.lifecycle.mock(),
+ });
+
+ let calledApi = false;
+ mswServer.use(
+ rest.put(
+ 'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
+ (_req, res, ctx) => {
+ calledApi = true;
+ res(ctx.status(200));
+ },
+ ),
+ );
+
+ await service.subscribe({
+ id: 'tester',
+ topics: ['test'],
+ async onEvent() {
+ expect('not').toBe('reached');
+ },
+ });
+
+ // Internal call to clean up subscriptions, and wait for the API call
+ await (service as any).shutdown();
+
+ expect(calledApi).toBe(false);
+ });
+
+ it('should deactivate event bus on 404', async () => {
+ expect.assertions(1);
+
+ const logger = mockServices.logger.mock();
+ const service = DefaultEventsService.create({ logger }).forPlugin('a', {
+ auth: mockServices.auth(),
+ logger,
+ discovery: mockServices.discovery(),
+ lifecycle: mockServices.lifecycle.mock(),
+ });
+
+ mswServer.use(
+ rest.put(
+ 'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
+ (_req, res, ctx) => res(ctx.status(404)),
+ ),
+ );
+
+ service.subscribe({
+ id: 'tester',
+ topics: ['test'],
+ async onEvent() {
+ expect('not').toBe('reached');
+ },
+ });
+
+ const msg = await new Promise(resolve => {
+ logger.warn.mockImplementationOnce(resolve);
+ });
+
+ expect(msg).toMatch(/Event subscribe request failed with status 404/);
+
+ // Internal call to clean up subscriptions
+ await (service as any).shutdown();
+ });
+
+ it('should not deactivate event bus if configured to always be used', async () => {
+ expect.assertions(1);
+
+ const logger = mockServices.logger.mock();
+ const service = DefaultEventsService.create({
+ logger,
+ useEventBus: 'always',
+ }).forPlugin('a', {
+ auth: mockServices.auth(),
+ logger,
+ discovery: mockServices.discovery(),
+ lifecycle: mockServices.lifecycle.mock(),
+ });
+
+ mswServer.use(
+ rest.put(
+ 'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
+ (_req, res, ctx) => res(ctx.status(404)),
+ ),
+ );
+
+ service.subscribe({
+ id: 'tester',
+ topics: ['test'],
+ async onEvent() {
+ expect('not').toBe('reached');
+ },
+ });
+
+ const msg = await new Promise(resolve => {
+ logger.warn.mockImplementationOnce(resolve);
+ });
+
+ expect(msg).toMatch(
+ 'Poll failed for subscription "a.tester", retrying in 1000ms',
+ );
+
+ // Internal call to clean up subscriptions
+ await (service as any).shutdown();
+ });
+ });
});
diff --git a/plugins/events-node/src/api/DefaultEventsService.ts b/plugins/events-node/src/api/DefaultEventsService.ts
index aa77d6e696..4eafd7d389 100644
--- a/plugins/events-node/src/api/DefaultEventsService.ts
+++ b/plugins/events-node/src/api/DefaultEventsService.ts
@@ -19,6 +19,7 @@ import {
DiscoveryService,
LifecycleService,
LoggerService,
+ RootConfigService,
} from '@backstage/backend-plugin-api';
import { EventParams } from './EventParams';
import { EventsService, EventsServiceSubscribeOptions } from './EventsService';
@@ -29,6 +30,13 @@ const POLL_BACKOFF_START_MS = 1_000;
const POLL_BACKOFF_MAX_MS = 60_000;
const POLL_BACKOFF_FACTOR = 2;
+const EVENT_BUS_MODES = ['never', 'always', 'auto'] as const;
+
+/**
+ * @public
+ */
+export type EventBusMode = 'never' | 'always' | 'auto';
+
/**
* Local event bus for subscribers within the same process.
*
@@ -107,23 +115,28 @@ class PluginEventsService implements EventsService {
private readonly pluginId: string,
private readonly localBus: LocalEventBus,
private readonly logger: LoggerService,
+ private readonly mode: EventBusMode,
private client?: DefaultApiClient,
private readonly auth?: AuthService,
) {}
async publish(params: EventParams): Promise {
const lock = this.#getShutdownLock();
+ if (!lock) {
+ throw new Error('Service is shutting down');
+ }
try {
const { notifiedSubscribers } = await this.localBus.publish(params);
- if (!this.client) {
+ const client = this.client;
+ if (!client) {
return;
}
const token = await this.#getToken();
if (!token) {
return;
}
- const res = await this.client.postEvent(
+ const res = await client.postEvent(
{
body: {
event: { payload: params.eventPayload, topic: params.topic },
@@ -134,7 +147,7 @@ class PluginEventsService implements EventsService {
);
if (!res.ok) {
- if (res.status === 404) {
+ if (res.status === 404 && this.mode !== 'always') {
this.logger.warn(
`Event publish request failed with status 404, events backend not found. Future events will not be persisted.`,
);
@@ -160,27 +173,6 @@ class PluginEventsService implements EventsService {
if (!this.client) {
return;
}
- const token = await this.#getToken();
- if (!token) {
- return;
- }
- const res = await this.client.putSubscription(
- {
- path: { subscriptionId },
- body: { topics: options.topics },
- },
- { token },
- );
- if (!res.ok) {
- if (res.status === 404) {
- this.logger.warn(
- `Event subscribe request failed with status 404, events backend not found. Will only receive events that were sent locally on this process.`,
- );
- delete this.client;
- return;
- }
- throw await ResponseError.fromResponse(res);
- }
this.#startPolling(subscriptionId, options.topics, options.onEvent);
}
@@ -190,75 +182,101 @@ class PluginEventsService implements EventsService {
topics: string[],
onEvent: EventsServiceSubscribeOptions['onEvent'],
) {
+ let hasSubscription = false;
let backoffMs = POLL_BACKOFF_START_MS;
const poll = async () => {
- if (!this.client) {
+ const client = this.client;
+ if (!client) {
return;
}
const lock = this.#getShutdownLock();
+ if (!lock) {
+ return; // shutting down
+ }
try {
const token = await this.#getToken();
if (!token) {
return;
}
- const res = await this.client.getSubscriptionEvents(
- {
- path: { subscriptionId },
- },
- { token },
- );
- if (!res.ok) {
- if (res.status === 404) {
- this.logger.info(
- `Polling event subscription resulted in a 404, recreating subscription`,
- );
- const putRes = await this.client.putSubscription(
- {
- path: { subscriptionId },
- body: { topics },
- },
- { token },
- );
- if (!putRes.ok) {
+ if (hasSubscription) {
+ const res = await client.getSubscriptionEvents(
+ {
+ path: { subscriptionId },
+ },
+ { token },
+ );
+
+ if (!res.ok) {
+ if (res.status === 404) {
+ this.logger.info(
+ `Polling event subscription resulted in a 404, recreating subscription`,
+ );
+ hasSubscription = false;
+ } else {
throw await ResponseError.fromResponse(res);
}
}
- throw await ResponseError.fromResponse(res);
- }
- backoffMs = POLL_BACKOFF_START_MS;
- // 202 means there were no immediately available events, but the
- // response will block until either new events are available or the
- // request times out. In both cases we should should try to read events
- // immediately again
- if (res.status === 202) {
- lock.release();
- await res.body?.getReader()?.closed;
- process.nextTick(poll);
- } else if (res.status === 200) {
- const data = await res.json();
- if (data) {
- for (const event of data.events ?? []) {
- try {
- await onEvent({
- topic: event.topic,
- eventPayload: event.payload,
- });
- } catch (error) {
- this.logger.warn(
- `Subscriber "${subscriptionId}" failed to process event for topic "${event.topic}"`,
- error,
- );
+ // Successful response, reset backoff
+ backoffMs = POLL_BACKOFF_START_MS;
+
+ // 202 means there were no immediately available events, but the
+ // response will block until either new events are available or the
+ // request times out. In both cases we should should try to read events
+ // immediately again
+ if (res.status === 202) {
+ lock.release();
+ await res.body?.getReader()?.closed;
+ process.nextTick(poll);
+ } else if (res.status === 200) {
+ const data = await res.json();
+ if (data) {
+ for (const event of data.events ?? []) {
+ try {
+ await onEvent({
+ topic: event.topic,
+ eventPayload: event.payload,
+ });
+ } catch (error) {
+ this.logger.warn(
+ `Subscriber "${subscriptionId}" failed to process event for topic "${event.topic}"`,
+ error,
+ );
+ }
}
+ } else {
+ this.logger.warn(
+ `Unexpected response status ${res.status} from events backend for subscription "${subscriptionId}"`,
+ );
}
}
- process.nextTick(poll);
- } else {
- this.logger.warn(
- `Unexpected response status ${res.status} from events backend for subscription "${subscriptionId}"`,
- );
}
+
+ // If we haven't yet created the subscription, or if it was removed, create a new one
+ if (!hasSubscription) {
+ const res = await client.putSubscription(
+ {
+ path: { subscriptionId },
+ body: { topics },
+ },
+ { token },
+ );
+ hasSubscription = true;
+ if (!res.ok) {
+ if (res.status === 404 && this.mode !== 'always') {
+ this.logger.warn(
+ `Event subscribe request failed with status 404, events backend not found. Will only receive events that were sent locally on this process.`,
+ );
+ // Events backend is not present and not configured to always be used, bail out and stop polling
+ delete this.client;
+ return;
+ }
+ throw await ResponseError.fromResponse(res);
+ }
+ }
+
+ process.nextTick(poll);
} catch (error) {
this.logger.warn(
`Poll failed for subscription "${subscriptionId}", retrying in ${backoffMs.toFixed(
@@ -292,7 +310,10 @@ class PluginEventsService implements EventsService {
} catch (error) {
// This is a bit hacky, but handles the case where new auth is used
// without legacy auth fallback, and the events backend is not installed
- if (String(error).includes('Unable to generate legacy token')) {
+ if (
+ String(error).includes('Unable to generate legacy token') &&
+ this.mode !== 'always'
+ ) {
this.logger.warn(
`The events backend is not available and neither is legacy auth. Future events will not be persisted.`,
);
@@ -309,22 +330,25 @@ class PluginEventsService implements EventsService {
}
#isShuttingDown = false;
- #shutdownLocks: Promise[] = [];
+ #shutdownLocks = new Set>();
// This locking mechanism helps ensure that we are either idle or waiting for
// a blocked events call before shutting down. It increases out changes of
// never dropping any events on shutdown.
- #getShutdownLock(): { release(): void } {
+ #getShutdownLock(): { release(): void } | undefined {
if (this.#isShuttingDown) {
- throw new Error('Service is shutting down');
+ return undefined;
}
let release: () => void;
- this.#shutdownLocks.push(
- new Promise(resolve => {
- release = resolve;
- }),
- );
+
+ const lock = new Promise(resolve => {
+ release = () => {
+ resolve();
+ this.#shutdownLocks.delete(lock);
+ };
+ });
+ this.#shutdownLocks.add(lock);
return { release: release! };
}
}
@@ -342,12 +366,30 @@ export class DefaultEventsService implements EventsService {
private constructor(
private readonly logger: LoggerService,
private readonly localBus: LocalEventBus,
+ private readonly mode: EventBusMode,
) {}
- static create(options: { logger: LoggerService }): DefaultEventsService {
+ static create(options: {
+ logger: LoggerService;
+ config?: RootConfigService;
+ useEventBus?: EventBusMode;
+ }): DefaultEventsService {
+ const eventBusMode =
+ options.useEventBus ??
+ ((options.config?.getOptionalString('events.useEventBus') ??
+ 'auto') as EventBusMode);
+ if (!EVENT_BUS_MODES.includes(eventBusMode)) {
+ throw new Error(
+ `Invalid events.useEventBus config, must be one of ${EVENT_BUS_MODES.join(
+ ', ',
+ )}, got '${eventBusMode}'`,
+ );
+ }
+
return new DefaultEventsService(
options.logger,
new LocalEventBus(options.logger),
+ eventBusMode,
);
}
@@ -367,16 +409,18 @@ export class DefaultEventsService implements EventsService {
},
): EventsService {
const client =
- options &&
- new DefaultApiClient({
- discoveryApi: options.discovery,
- fetchApi: { fetch }, // use native node fetch
- });
+ options && this.mode !== 'never'
+ ? new DefaultApiClient({
+ discoveryApi: options.discovery,
+ fetchApi: { fetch }, // use native node fetch
+ })
+ : undefined;
const logger = options?.logger ?? this.logger;
const service = new PluginEventsService(
pluginId,
this.localBus,
logger,
+ this.mode,
client,
options?.auth,
);
diff --git a/plugins/events-node/src/api/index.ts b/plugins/events-node/src/api/index.ts
index 94d3014dff..6f3817cedc 100644
--- a/plugins/events-node/src/api/index.ts
+++ b/plugins/events-node/src/api/index.ts
@@ -21,6 +21,9 @@ export type {
EventsServiceSubscribeOptions,
EventsServiceEventHandler,
} from './EventsService';
-export { DefaultEventsService } from './DefaultEventsService';
+export {
+ DefaultEventsService,
+ type EventBusMode,
+} from './DefaultEventsService';
export * from './http';
export { SubTopicEventRouter } from './SubTopicEventRouter';
diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx
index ba81ee4bd5..809291a9f7 100644
--- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx
+++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx
@@ -124,16 +124,15 @@ export const Stepper = (stepperProps: StepperProps) => {
const apiHolder = useApiHolder();
const [activeStep, setActiveStep] = useState(0);
const [isValidating, setIsValidating] = useState(false);
+
const [initialState] = useFormDataFromQuery(props.initialState);
- const [formState, setFormState] = useState<{
- [step: string]: Record;
- }>();
+ const [stepsState, setStepsState] = useState[]>(
+ steps.map(() => initialState),
+ );
const [errors, setErrors] = useState();
const styles = useStyles();
- const makeStepKey = (step: string | number) => `step-${step}`;
-
const backLabel =
presentation?.buttonLabels?.backButtonText ?? backButtonText;
const createLabel =
@@ -168,15 +167,15 @@ export const Stepper = (stepperProps: StepperProps) => {
setActiveStep(prevActiveStep => prevActiveStep - 1);
};
- const handleChange = useCallback(
- (e: IChangeEvent) => {
- setFormState(current => ({
- ...current,
- [makeStepKey(activeStep)]: e.formData,
- }));
- },
- [setFormState, activeStep],
- );
+ const handleChange = (e: IChangeEvent) => {
+ setStepsState(current => {
+ const newState = [...current];
+ newState[activeStep] = {
+ ...e.formData,
+ };
+ return newState;
+ });
+ };
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
@@ -197,6 +196,13 @@ export const Stepper = (stepperProps: StepperProps) => {
if (hasErrors(returnedValidation)) {
setErrors(returnedValidation);
} else {
+ setStepsState(current => {
+ const newState = [...current];
+ newState[activeStep] = {
+ ...formData,
+ };
+ return newState;
+ });
setErrors(undefined);
setActiveStep(prevActiveStep => {
const stepNum = prevActiveStep + 1;
@@ -204,10 +210,6 @@ export const Stepper = (stepperProps: StepperProps) => {
return stepNum;
});
}
- setFormState(current => ({
- ...current,
- [makeStepKey(activeStep)]: formData,
- }));
};
const {
@@ -218,23 +220,14 @@ export const Stepper = (stepperProps: StepperProps) => {
const mergedUiSchema = merge({}, propUiSchema, currentStep?.uiSchema);
- const mergedState = useMemo(() => {
- if (!formState) {
- return initialState;
- }
- const { [makeStepKey(activeStep)]: activeState, ...historicalState } =
- formState;
- const chronologicalState = {
- ...historicalState,
- [makeStepKey(activeStep)]: activeState,
- };
- return merge({}, ...Object.values(chronologicalState));
- }, [formState, activeStep, initialState]);
+ const formState = stepsState.reduce((acc, step) => {
+ return { ...acc, ...step };
+ }, {});
const handleCreate = useCallback(() => {
- props.onCreate(mergedState);
+ props.onCreate(formState);
analytics.captureEvent('click', `${createLabel}`);
- }, [props, mergedState, analytics, createLabel]);
+ }, [props, formState, analytics, createLabel]);
return (
<>
@@ -269,12 +262,15 @@ export const Stepper = (stepperProps: StepperProps) => {
{/* eslint-disable-next-line no-nested-ternary */}
{activeStep < steps.length ? (