Merge branch 'master' into storybook8
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
---
|
||||
|
||||
Fix issue with form state not refreshing when updating
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Revert the change of the option label for `EntityPicker`
|
||||
@@ -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.
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<backstage-element attribute1="value"></backstage-element>` 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+33
-39
@@ -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 = {
|
||||
|
||||
Vendored
+34
@@ -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';
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<TPayload = unknown> {
|
||||
eventPayload: TPayload;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>[] = [];
|
||||
#shutdownLocks = new Set<Promise<void>>();
|
||||
|
||||
// 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<void>(resolve => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const lock = new Promise<void>(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,
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, JsonValue>;
|
||||
}>();
|
||||
const [stepsState, setStepsState] = useState<Record<string, JsonValue>[]>(
|
||||
steps.map(() => initialState),
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<undefined | FormValidation>();
|
||||
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 ? (
|
||||
<Form
|
||||
key={activeStep}
|
||||
validator={validator}
|
||||
extraErrors={errors as unknown as ErrorSchema}
|
||||
formData={mergedState}
|
||||
formContext={{ ...propFormContext, formData: mergedState }}
|
||||
formData={{ ...stepsState[activeStep] }}
|
||||
formContext={{ ...propFormContext, formData: formState }}
|
||||
schema={currentStep.schema}
|
||||
uiSchema={mergedUiSchema}
|
||||
omitExtraData
|
||||
liveOmit
|
||||
onSubmit={handleNext}
|
||||
fields={fields}
|
||||
showErrorList="top"
|
||||
@@ -308,7 +304,7 @@ export const Stepper = (stepperProps: StepperProps) => {
|
||||
ReviewStepComponent ? (
|
||||
<ReviewStepComponent
|
||||
disableButtons={isValidating}
|
||||
formData={mergedState}
|
||||
formData={formState}
|
||||
handleBack={handleBack}
|
||||
handleReset={() => {}}
|
||||
steps={steps}
|
||||
@@ -316,7 +312,7 @@ export const Stepper = (stepperProps: StepperProps) => {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ReviewStateComponent formState={mergedState} schemas={steps} />
|
||||
<ReviewStateComponent formState={formState} schemas={steps} />
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
|
||||
@@ -199,7 +199,7 @@ export const EntityPicker = (props: EntityPickerProps) => {
|
||||
typeof option === 'string'
|
||||
? option
|
||||
: entities?.entityRefToPresentation.get(stringifyEntityRef(option))
|
||||
?.primaryTitle!
|
||||
?.entityRef!
|
||||
}
|
||||
autoSelect
|
||||
freeSolo={allowArbitraryValues}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { EventParams, EventsService } from '@backstage/plugin-events-node';
|
||||
import { SignalPayload } from '@backstage/plugin-signals-node';
|
||||
import crypto from 'crypto';
|
||||
import { RawData, WebSocket } from 'ws';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
@@ -62,10 +63,17 @@ export class SignalManager {
|
||||
}
|
||||
|
||||
private constructor(options: SignalManagerOptions) {
|
||||
({ events: this.events, logger: this.logger } = options);
|
||||
this.events = options.events;
|
||||
|
||||
// Use a unique subscriber ID for each signals instance, in order to fan-out
|
||||
// all events to each signals instance. This ensures that events always
|
||||
// reach users in a scaled deployment.
|
||||
const id = `signals-${crypto.randomBytes(8).toString('hex')}`;
|
||||
this.logger = options.logger.child({ subscriberId: id });
|
||||
this.logger.info(`Signals manager is subscribing to signals events`);
|
||||
|
||||
this.events.subscribe({
|
||||
id: 'signals',
|
||||
id,
|
||||
topics: ['signals'],
|
||||
onEvent: (params: EventParams) =>
|
||||
this.onEventBrokerEvent(params.eventPayload as SignalPayload),
|
||||
|
||||
Vendored
+16
@@ -42,6 +42,22 @@ export interface Config {
|
||||
* @visibility frontend
|
||||
*/
|
||||
allowedIframeHosts?: string[];
|
||||
/**
|
||||
* Allows listed custom element tag name regex
|
||||
* Example:
|
||||
* allowedCustomElementTagNameRegExp: '^backstage-'
|
||||
* this will allow all custom elements with tag name matching `^backstage-` like <backstage-custom-element /> etc.
|
||||
* @visibility frontend
|
||||
*/
|
||||
allowedCustomElementTagNameRegExp: string;
|
||||
/**
|
||||
* Allows listed custom element attribute name regex
|
||||
* Example:
|
||||
* allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2'
|
||||
* this will allow all custom element attributes matching `attribute1` or `attribute2` like <backstage-custom-element attribute1="yes" attribute2/>
|
||||
* @visibility frontend
|
||||
*/
|
||||
allowedCustomElementAttributeNameRegExp: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
import React, { FC, PropsWithChildren } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { useSanitizerTransformer } from './transformer';
|
||||
|
||||
const configApiMock: ConfigApi = new ConfigReader({
|
||||
techdocs: {
|
||||
sanitizer: {
|
||||
allowedCustomElementTagNameRegExp: '^backstage-',
|
||||
allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper: FC<PropsWithChildren<{}>> = ({ children }) => (
|
||||
<TestApiProvider apis={[[configApiRef, configApiMock]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
describe('Transformers > Html > Sanitizer Custom Elements', () => {
|
||||
it('should return a function that allows custom elements matching the pattern in the given dom element', async () => {
|
||||
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
|
||||
|
||||
const dirtyDom = document.createElement('html');
|
||||
dirtyDom.innerHTML = `
|
||||
<body>
|
||||
<backstage-element attribute1="test" attribute2></backstage-element>
|
||||
</body>
|
||||
`;
|
||||
const clearDom = await result.current(dirtyDom); // calling html transformer
|
||||
|
||||
const elements = Array.from(
|
||||
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
|
||||
);
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
|
||||
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return a function that removes custom elements not matching the pattern in the given dom element', async () => {
|
||||
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
|
||||
|
||||
const dirtyDom = document.createElement('html');
|
||||
dirtyDom.innerHTML = `
|
||||
<body>
|
||||
<backstage-element attribute1="test" attribute2></backstage-element>
|
||||
<invalid-element attribute1="test" attribute2></invalid-element>
|
||||
</body>
|
||||
`;
|
||||
const clearDom = await result.current(dirtyDom); // calling html transformer
|
||||
|
||||
const elements = Array.from(
|
||||
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
|
||||
);
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
|
||||
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return a function that removes custom element attributes not matching the pattern in the given dom element', async () => {
|
||||
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
|
||||
|
||||
const dirtyDom = document.createElement('html');
|
||||
dirtyDom.innerHTML = `
|
||||
<body>
|
||||
<backstage-element attribute1="test" attribute2></backstage-element>
|
||||
<backstage-element attribute3="test" attribute4></backstage-element>
|
||||
</body>
|
||||
`;
|
||||
const clearDom = await result.current(dirtyDom); // calling html transformer
|
||||
|
||||
const elements = Array.from(
|
||||
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
|
||||
);
|
||||
expect(elements).toHaveLength(2);
|
||||
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
|
||||
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
|
||||
expect(elements[1].hasAttribute('attribute3')).toEqual(false);
|
||||
expect(elements[1].hasAttribute('attribute4')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,13 @@ export const useSanitizerTransformer = (): Transformer => {
|
||||
}
|
||||
});
|
||||
|
||||
const tagNameCheck = config?.getOptionalString(
|
||||
'allowedCustomElementTagNameRegExp',
|
||||
);
|
||||
const attributeNameCheck = config?.getOptionalString(
|
||||
'allowedCustomElementAttributeNameRegExp',
|
||||
);
|
||||
|
||||
// using outerHTML as we want to preserve the html tag attributes (lang)
|
||||
return DOMPurify.sanitize(dom.outerHTML, {
|
||||
ADD_TAGS: tags,
|
||||
@@ -79,6 +86,12 @@ export const useSanitizerTransformer = (): Transformer => {
|
||||
ADD_ATTR: ['http-equiv', 'content'],
|
||||
WHOLE_DOCUMENT: true,
|
||||
RETURN_DOM: true,
|
||||
CUSTOM_ELEMENT_HANDLING: {
|
||||
tagNameCheck: tagNameCheck ? new RegExp(tagNameCheck) : undefined,
|
||||
attributeNameCheck: attributeNameCheck
|
||||
? new RegExp(attributeNameCheck)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
[config],
|
||||
|
||||
@@ -6325,6 +6325,7 @@ __metadata:
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
cross-fetch: ^4.0.0
|
||||
msw: ^1.0.0
|
||||
uri-template: ^2.0.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -17164,8 +17165,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/jest-dom@npm:^6.0.0":
|
||||
version: 6.6.1
|
||||
resolution: "@testing-library/jest-dom@npm:6.6.1"
|
||||
version: 6.6.2
|
||||
resolution: "@testing-library/jest-dom@npm:6.6.2"
|
||||
dependencies:
|
||||
"@adobe/css-tools": ^4.4.0
|
||||
aria-query: ^5.0.0
|
||||
@@ -17174,7 +17175,7 @@ __metadata:
|
||||
dom-accessibility-api: ^0.6.3
|
||||
lodash: ^4.17.21
|
||||
redent: ^3.0.0
|
||||
checksum: cad9d8924857438ae9c3da9b42271330f58e86dcb4d6b9ac2573ec668f4418f9b52678ca9af97c3c1c15a14e601d1ec2dee31be3dea8d9c38c36d0f2b3529ef5
|
||||
checksum: 23fc4de90035b7acaa13839adb8e31e5f5ad0306623f59be4907fe48050a5d148cd927ff6b6c13c49e392cc06bdd9f16e7d4766263dd7b42d5a342897e32bb6f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29399,8 +29400,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"html-webpack-plugin@npm:^5.3.1":
|
||||
version: 5.6.0
|
||||
resolution: "html-webpack-plugin@npm:5.6.0"
|
||||
version: 5.6.2
|
||||
resolution: "html-webpack-plugin@npm:5.6.2"
|
||||
dependencies:
|
||||
"@types/html-minifier-terser": ^6.0.0
|
||||
html-minifier-terser: ^6.0.2
|
||||
@@ -29415,7 +29416,7 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 32a6e41da538e798fd0be476637d7611a5e8a98a3508f031996e9eb27804dcdc282cb01f847cf5d066f21b49cfb8e21627fcf977ffd0c9bea81cf80e5a65070d
|
||||
checksum: c579ce8b34ef1cd903829402aa6a62a6c92fe1cdfcd81d17ebc87f39eaab1381438b9d805a63457b255238db8f2865d71f48cd382375aa28718881e3ab2d2f9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user