From af853ef1bcae32064979fe17b197d5c85c10120d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 15 Apr 2025 10:45:12 +0200 Subject: [PATCH] Add `events-backend-module-google-pubsub` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/warm-llamas-return.md | 5 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 1 + .../.eslintrc.js | 1 + .../README.md | 42 ++++ .../catalog-info.yaml | 10 + .../config.d.ts | 96 +++++++ .../dev/index.ts | 62 +++++ .../package.json | 54 ++++ .../report.api.md | 11 + ...ooglePubSubConsumingEventPublisher.test.ts | 120 +++++++++ .../GooglePubSubConsumingEventPublisher.ts | 210 ++++++++++++++++ .../config.test.ts | 234 ++++++++++++++++++ .../config.ts | 178 +++++++++++++ .../index.ts | 17 ++ .../module.ts | 52 ++++ .../types.ts | 28 +++ .../src/index.ts | 23 ++ yarn.lock | 84 ++++++- 19 files changed, 1226 insertions(+), 3 deletions(-) create mode 100644 .changeset/warm-llamas-return.md create mode 100644 plugins/events-backend-module-google-pubsub/.eslintrc.js create mode 100644 plugins/events-backend-module-google-pubsub/README.md create mode 100644 plugins/events-backend-module-google-pubsub/catalog-info.yaml create mode 100644 plugins/events-backend-module-google-pubsub/config.d.ts create mode 100644 plugins/events-backend-module-google-pubsub/dev/index.ts create mode 100644 plugins/events-backend-module-google-pubsub/package.json create mode 100644 plugins/events-backend-module-google-pubsub/report.api.md create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.test.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.test.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/index.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/module.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/types.ts create mode 100644 plugins/events-backend-module-google-pubsub/src/index.ts diff --git a/.changeset/warm-llamas-return.md b/.changeset/warm-llamas-return.md new file mode 100644 index 0000000000..743ad0a5bb --- /dev/null +++ b/.changeset/warm-llamas-return.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-events-backend-module-google-pubsub': minor +--- + +Added a module that is able to transfer messages from Google Pub/Sub subscriptions into the Backstage events system. diff --git a/packages/backend/package.json b/packages/backend/package.json index 99c48c4793..612c881cf1 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-catalog-backend-module-unprocessed": "workspace:^", "@backstage/plugin-devtools-backend": "workspace:^", "@backstage/plugin-events-backend": "workspace:^", + "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^", "@backstage/plugin-kubernetes-backend": "workspace:^", "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8ae9864a4a..23d814f1ae 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -61,4 +61,5 @@ backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@backstage/plugin-notifications-backend')); backend.add(import('./instanceMetadata')); +backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); backend.start(); diff --git a/plugins/events-backend-module-google-pubsub/.eslintrc.js b/plugins/events-backend-module-google-pubsub/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/events-backend-module-google-pubsub/README.md b/plugins/events-backend-module-google-pubsub/README.md new file mode 100644 index 0000000000..67ddcfacb4 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/README.md @@ -0,0 +1,42 @@ +# @backstage/plugin-events-backend-module-google-pubsub + +This package is a module for the `events-backend` backend plugin +and extends the events system with Google Pub/Sub related capabilities. + +## Configuration + +The following configuration enables the transfer of messages from a Google +Pub/Sub subscription into a topic on the Backstage events system. + +```yaml +events: + modules: + googlePubSub: + googlePubSubConsumingEventPublisher: + subscriptions: + # A unique key for your subscription, to be used in logging and metrics + mySubscription: + # The fully qualified name of the subscription + subscriptionName: 'projects/my-google-project/subscriptions/github-enterprise-events' + # The event system topic to transfer to. This can also be just a plain string + targetTopic: + # This example picks the topic name from a message attribute + a prefix + fromMessageAttribute: + attributeName: 'x-github-event' + withPrefix: 'github.' +``` + +## Installation + +1. Install this module +2. Add your configuration. + +```bash +# From your Backstage root directory +yarn --cwd packages/backend add @backstage/plugin-events-backend-module-google-pubsub +``` + +```ts +// packages/backend/src/index.ts +backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); +``` diff --git a/plugins/events-backend-module-google-pubsub/catalog-info.yaml b/plugins/events-backend-module-google-pubsub/catalog-info.yaml new file mode 100644 index 0000000000..2cf0459a30 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-events-backend-module-google-pubsub + title: '@backstage/plugin-events-backend-module-google-pubsub' + description: The google-pubsub backend module for the events plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/events-backend-module-google-pubsub/config.d.ts b/plugins/events-backend-module-google-pubsub/config.d.ts new file mode 100644 index 0000000000..3e70489b9d --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/config.d.ts @@ -0,0 +1,96 @@ +/* + * 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. + */ + +export interface Config { + events?: { + modules?: { + /** + * events-backend-module-google-pubsub configuration. + */ + googlePubSub?: { + /** + * Configuration for `GooglePubSubConsumingEventPublisher`, which + * consumes messages from a Google Pub/Sub subscription and forwards + * them into the Backstage events system. + */ + googlePubSubConsumingEventPublisher?: { + /** + * Generally contains a record per subscription to consume. + */ + subscriptions: { + /** + * The name can be anything, but it is recommended to use only + * letters, numbers, and hyphens for this identifier since it will + * appear in logs and metric names etc. + */ + [name: string]: { + /** + * The complete name of the Pub/Sub subscription to be used, on the + * form + * `projects/PROJECT_ID/subscriptions/SUBSCRIPTION_ID`. + */ + subscriptionName: string; + + /** + * The name of the events backend topic to which the messages are + * to be forwarded. + * + * @remarks + * + * The value can contain placeholders on the form `{{ message.attributes.foo }}`, + * to mirror attribute `foo` as the whole or part of the topic name. + * + * @example + * + * This example expects the Pub/Sub topic to contain GitHub + * webhook events where the HTTP headers were mapped into + * message attributes. The outcome should be that messages + * end up on event topics such as `github.push`, + * `github.repository` etc which matches the [`@backstage/plugin-events-backend-module-github`](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-github) structure. + * + * ```yaml + * targetTopic: 'github.{{ message.attributes.x-github-event }}' + * ``` + */ + targetTopic: string; + + /** + * Pub/Sub message attributes are by default copied to the event + * metadata field. This setting allows you to override or amend + * that metadata. + * + * @remarks + * + * The values can contain placeholders on the form `{{ message.attributes.foo }}`, + * to mirror attribute `foo` as the whole or part of a metadata value. + * + * @example + * + * ```yaml + * eventMetadata: + * x-gitHub-event: '{{ message.attributes.event }}' + * ``` + */ + eventMetadata?: { + [key: string]: string; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/plugins/events-backend-module-google-pubsub/dev/index.ts b/plugins/events-backend-module-google-pubsub/dev/index.ts new file mode 100644 index 0000000000..752217e6db --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/dev/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 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 { createBackend } from '@backstage/backend-defaults'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; + +const backend = createBackend(); + +backend.add(import('@backstage/plugin-events-backend')); +backend.add(import('../src')); + +backend.add( + createBackendPlugin({ + pluginId: 'example-event-consumer', + register(reg) { + reg.registerInit({ + deps: { + events: eventsServiceRef, + logger: coreServices.logger, + }, + async init({ events, logger }) { + events.subscribe({ + id: 'example-subscription', + topics: ['example-topic'], + async onEvent(event) { + logger.info( + `Received event: ${JSON.stringify( + { + topic: event.topic, + payload: event.eventPayload, + metadata: event.metadata, + }, + null, + 2, + )}`, + ); + }, + }); + }, + }); + }, + }), +); + +backend.start(); diff --git a/plugins/events-backend-module-google-pubsub/package.json b/plugins/events-backend-module-google-pubsub/package.json new file mode 100644 index 0000000000..574747874f --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/plugin-events-backend-module-google-pubsub", + "version": "0.0.0", + "description": "The google-pubsub backend module for the events plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "events", + "pluginPackage": "@backstage/plugin-events-backend" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/events-backend-module-google-pubsub" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "config.d.ts", + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", + "@backstage/types": "workspace:^", + "@google-cloud/pubsub": "^4.10.0", + "@opentelemetry/api": "^1.9.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-events-backend": "workspace:^", + "wait-for-expect": "^3.0.2" + }, + "configSchema": "config.d.ts" +} diff --git a/plugins/events-backend-module-google-pubsub/report.api.md b/plugins/events-backend-module-google-pubsub/report.api.md new file mode 100644 index 0000000000..bdb057d7fc --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@backstage/plugin-events-backend-module-google-pubsub" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const eventsModuleGooglePubsubConsumingEventPublisher: BackendFeature; +export default eventsModuleGooglePubsubConsumingEventPublisher; +``` diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.test.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.test.ts new file mode 100644 index 0000000000..60c6816729 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2025 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 { mockServices } from '@backstage/backend-test-utils'; +import { Message, PubSub } from '@google-cloud/pubsub'; +import { GooglePubSubConsumingEventPublisher } from './GooglePubSubConsumingEventPublisher'; +import { JsonObject } from '@backstage/types'; +import waitFor from 'wait-for-expect'; + +function makeMessage( + data: JsonObject, + attributes: Record, +): Message { + const message: Partial = { + attributes, + data: Buffer.from(JSON.stringify(data), 'utf-8'), + ack: jest.fn(), + nack: jest.fn(), + }; + return message as Message; +} + +describe('GooglePubSubConsumingEventPublisher', () => { + const logger = mockServices.logger.mock(); + const events = mockServices.events.mock(); + + let onMessage: undefined | ((message: Message) => void); + const subscription = { + close: jest.fn(), + on: jest.fn((event, fn) => { + if (event === 'message') { + onMessage = fn; + } + }), + }; + + const pubSub = { + close: jest.fn(), + subscription: jest.fn(() => subscription), + }; + const pubSubFactory = jest.fn(() => pubSub as unknown as PubSub); + + beforeEach(() => { + onMessage = undefined; + jest.clearAllMocks(); + }); + + it('should go though the expected registration and flows', async () => { + const publisher = new GooglePubSubConsumingEventPublisher({ + logger, + events, + tasks: [ + { + id: 'my-id', + project: 'my-project', + subscription: 'my-subscription', + mapToTopic: () => 'my-topic', + mapToMetadata: m => ({ ...m.attributes, more: 'yes' }), + }, + ], + pubSubFactory, + }); + + // Start up + + await publisher.start(); + + expect(pubSubFactory).toHaveBeenCalledWith('my-project'); + expect(pubSub.subscription).toHaveBeenCalledWith( + 'my-subscription', + expect.anything(), + ); + + // Publish successfully + + let message = makeMessage({ foo: 'bar' }, { extra: 'data' }); + onMessage!(message); + + await waitFor(() => { + expect(events.publish).toHaveBeenCalledWith({ + topic: 'my-topic', + eventPayload: { foo: 'bar' }, + metadata: { extra: 'data', more: 'yes' }, + }); + expect(message.ack).toHaveBeenCalled(); + }); + + // Simulate a failed publish + + events.publish.mockRejectedValueOnce(new Error('Failed to publish')); + + message = makeMessage({ foo: 'bar' }, { extra: 'data' }); + onMessage!(message); + + await waitFor(() => { + expect(message.nack).toHaveBeenCalled(); + expect(message.ack).not.toHaveBeenCalled(); + }); + + // Shut down + + await publisher.stop(); + + expect(subscription.close).toHaveBeenCalled(); + expect(pubSub.close).toHaveBeenCalled(); + }); +}); diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.ts new file mode 100644 index 0000000000..0461c2da51 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/GooglePubSubConsumingEventPublisher.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2025 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 { + LoggerService, + RootConfigService, + RootLifecycleService, +} from '@backstage/backend-plugin-api'; +import { EventParams, EventsService } from '@backstage/plugin-events-node'; +import { Message, PubSub, Subscription } from '@google-cloud/pubsub'; +import { Counter, metrics } from '@opentelemetry/api'; +import { readSubscriptionTasksFromConfig } from './config'; +import { SubscriptionTask } from './types'; + +/** + * Reads messages off of Google Pub/Sub subscriptions and forwards them into the + * Backstage events system. + */ +export class GooglePubSubConsumingEventPublisher { + readonly #logger: LoggerService; + readonly #events: EventsService; + readonly #tasks: SubscriptionTask[]; + readonly #pubSubFactory: (projectId: string) => PubSub; + readonly #metrics: { messages: Counter }; + #activeClientsByProjectId: Map; + #activeSubscriptions: Subscription[]; + + static create(options: { + config: RootConfigService; + logger: LoggerService; + rootLifecycle: RootLifecycleService; + events: EventsService; + }) { + const publisher = new GooglePubSubConsumingEventPublisher({ + logger: options.logger, + events: options.events, + tasks: readSubscriptionTasksFromConfig(options.config), + pubSubFactory: projectId => new PubSub({ projectId }), + }); + + options.rootLifecycle.addStartupHook(async () => { + await publisher.start(); + }); + + options.rootLifecycle.addBeforeShutdownHook(async () => { + await publisher.stop(); + }); + + return publisher; + } + + constructor(options: { + logger: LoggerService; + events: EventsService; + tasks: SubscriptionTask[]; + pubSubFactory: (projectId: string) => PubSub; + }) { + this.#logger = options.logger; + this.#events = options.events; + this.#tasks = options.tasks; + this.#pubSubFactory = options.pubSubFactory; + + const meter = metrics.getMeter('default'); + this.#metrics = { + messages: meter.createCounter( + 'events.google.pubsub.consumer.messages.total', + { + description: + 'Number of Pub/Sub messages received by GooglePubSubConsumingEventPublisher', + unit: 'short', + }, + ), + }; + + this.#activeClientsByProjectId = new Map(); + this.#activeSubscriptions = []; + } + + async start() { + for (const task of this.#tasks) { + this.#logger.info( + `Starting subscription: id=${task.id} project=${task.project} subscription=${task.subscription}`, + ); + + let pubsub = this.#activeClientsByProjectId.get(task.project); + if (!pubsub) { + pubsub = this.#pubSubFactory(task.project); + this.#activeClientsByProjectId.set(task.project, pubsub); + } + + // You cannot control the actual batch size delivered to the client from + // pubsub, so these settings actually instead control the rate at which + // messages are released to our event handlers by the pubsub library. This + // means that there may be significantly more than maxMessages messages + // pending in memory before we see them. Thus, the settings here are rather + // chosen so as to limit the concurrency of hammering consumers (the catalog + // etc). + const subscription = pubsub.subscription(task.subscription, { + flowControl: { + maxMessages: 5, + allowExcessMessages: false, + }, + }); + + this.#activeSubscriptions.push(subscription); + + subscription.on('error', error => { + this.#logger.error( + `Error reading Google Pub/Sub subscription: ${task.id}`, + error, + ); + }); + + subscription.on('message', async message => { + let event: EventParams; + try { + event = this.#messageToEvent(message, task)!; + if (!event) { + this.#metrics.messages.add(1, { + topic: 'unknown', + status: 'ignored', + }); + return; + } + } catch (error) { + this.#logger.error('Error processing Google Pub/Sub message', error); + this.#metrics.messages.add(1, { + topic: 'unknown', + status: 'failed', + }); + // We unconditionally ACK the message in this case, because if it's + // broken, it will still be broken next time around, so there is no + // point in re-delivering it. + message.ack(); + return; + } + + try { + await this.#events.publish(event); + this.#metrics.messages.add(1, { + topic: event.topic, + status: 'success', + }); + message.ack(); + } catch (error) { + this.#logger.error('Error processing Google Pub/Sub message', error); + this.#metrics.messages.add(1, { + topic: event.topic, + status: 'failed', + }); + // We fast-NACK the message in this case because this may be a + // transient problem with the events backend. + message.nack(); + } + }); + } + } + + async stop() { + const subscriptions = this.#activeSubscriptions; + const clients = Array.from(this.#activeClientsByProjectId.values()); + + this.#activeSubscriptions = []; + this.#activeClientsByProjectId = new Map(); + + await Promise.allSettled( + subscriptions.map(async subscription => { + this.#logger.info( + `Closing Google Pub/Sub subscription: ${subscription.name}`, + ); + await subscription.close(); + }), + ); + + await Promise.allSettled( + clients.map(async client => { + this.#logger.info(`Closing Google Pub/Sub client: ${client.projectId}`); + await client.close(); + }), + ); + } + + #messageToEvent( + message: Message, + task: SubscriptionTask, + ): EventParams | undefined { + const topic = task.mapToTopic(message); + if (!topic) { + return undefined; + } + return { + topic, + eventPayload: JSON.parse(message.data.toString()), + metadata: task.mapToMetadata(message), + }; + } +} diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.test.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.test.ts new file mode 100644 index 0000000000..ab690f2050 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright 2025 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 { mockServices } from '@backstage/backend-test-utils'; +import { JsonObject } from '@backstage/types'; +import { Message } from '@google-cloud/pubsub'; +import { + createPatternResolver, + readSubscriptionTasksFromConfig, +} from './config'; + +function makeMessage( + data: JsonObject, + attributes: Record, +): Message { + const message: Partial = { + attributes, + data: Buffer.from(JSON.stringify(data), 'utf-8'), + ack: jest.fn(), + nack: jest.fn(), + }; + return message as Message; +} + +describe('readSubscriptionTasksFromConfig', () => { + it('reads with basic targetTopic', () => { + const data = { + events: { + modules: { + googlePubSub: { + googlePubSubConsumingEventPublisher: { + subscriptions: { + subKey: { + subscriptionName: 'projects/pid/subscriptions/sid', + targetTopic: 'my-topic', + }, + }, + }, + }, + }, + }, + }; + + const result = readSubscriptionTasksFromConfig( + mockServices.rootConfig({ data }), + ); + + expect(result).toEqual([ + { + id: 'subKey', + project: 'pid', + subscription: 'sid', + mapToTopic: expect.any(Function), + mapToMetadata: expect.any(Function), + }, + ]); + + expect( + result[0].mapToTopic(makeMessage({ foo: 'bar' }, { attr: 'yes' })), + ).toBe('my-topic'); + expect( + result[0].mapToMetadata(makeMessage({ foo: 'bar' }, { attr: 'yes' })), + ).toEqual({ attr: 'yes' }); + }); + + it('fills in placeholders', () => { + const data = { + events: { + modules: { + googlePubSub: { + googlePubSubConsumingEventPublisher: { + subscriptions: { + sub1: { + subscriptionName: 'projects/pid/subscriptions/sid', + targetTopic: 't.{{ message.attributes.exists }}', + eventMetadata: { + meta1: 'updated.{{ message.attributes.exists }}', + meta2: 'updated.{{ message.attributes.missing }}', + }, + }, + sub2: { + subscriptionName: 'projects/pid/subscriptions/sid', + targetTopic: 't.{{ message.attributes.missing }}', + eventMetadata: { + meta3: 'new', + }, + }, + }, + }, + }, + }, + }, + }; + + const result = readSubscriptionTasksFromConfig( + mockServices.rootConfig({ data }), + ); + + expect(result).toEqual([ + { + id: 'sub1', + project: 'pid', + subscription: 'sid', + mapToTopic: expect.any(Function), + mapToMetadata: expect.any(Function), + }, + { + id: 'sub2', + project: 'pid', + subscription: 'sid', + mapToTopic: expect.any(Function), + mapToMetadata: expect.any(Function), + }, + ]); + + expect( + result[0].mapToTopic( + makeMessage( + { foo: 'bar' }, + { exists: 'exists', meta1: 'original1', meta2: 'original2' }, + ), + ), + ).toBe('t.exists'); // Message attribute existed, successfully routed + expect( + result[0].mapToMetadata( + makeMessage( + { foo: 'bar' }, + { exists: 'exists', meta1: 'original1', meta2: 'original2' }, + ), + ), + ).toEqual({ + exists: 'exists', + meta1: 'updated.exists', // message attribute existed, was replaced + meta2: 'original2', // message attribute did not exist, was not replaced + }); + + expect( + result[1].mapToTopic( + makeMessage( + { foo: 'bar' }, + { exists: 'exists', meta1: 'original1', meta2: 'original2' }, + ), + ), + ).toBeUndefined(); // Message attribute did not exist, could not be routed + expect( + result[1].mapToMetadata( + makeMessage( + { foo: 'bar' }, + { exists: 'exists', meta1: 'original1', meta2: 'original2' }, + ), + ), + ).toEqual({ + exists: 'exists', + meta1: 'original1', + meta2: 'original2', + meta3: 'new', + }); + }); + + it('rejects malformed subscription name', () => { + const data = { + events: { + modules: { + googlePubSub: { + googlePubSubConsumingEventPublisher: { + subscriptions: { + subKey: { + subscriptionName: 'sid', + targetTopic: 'foo', + }, + }, + }, + }, + }, + }, + }; + + expect(() => + readSubscriptionTasksFromConfig(mockServices.rootConfig({ data })), + ).toThrowErrorMatchingInlineSnapshot( + `"Expected Googoe Pub/Sub 'subscriptionName' to be on the form 'projects/PROJECT_ID/subscriptions/SUBSCRIPTION_ID' but got 'sid'"`, + ); + }); +}); + +describe('createPatternResolver', () => { + it('resolves patterns as expected', () => { + expect(createPatternResolver('foo')({})).toEqual('foo'); + expect(createPatternResolver('{{a.b}}')({ a: { b: 'test' } })).toEqual( + 'test', + ); + expect(createPatternResolver('{{a.b}}')({ a: { b: '7' } })).toEqual('7'); + expect( + createPatternResolver('{{a.b-c}}')({ a: { 'b-c': 'test' } }), + ).toEqual('test'); + expect( + createPatternResolver("{{a['b-c']}}")({ a: { 'b-c': 'test' } }), + ).toEqual('test'); + expect( + createPatternResolver(' {{ a.b }} ')({ a: { b: ' test ' } }), + ).toEqual(' test '); + }); + + it('throws on bad / missing context values', () => { + expect(() => + createPatternResolver('{{a.b}}')({ a: { b: [7] } }), + ).toThrowErrorMatchingInlineSnapshot( + `"Expected string or number value for selector 'a.b', got object"`, + ); + expect(() => + createPatternResolver('{{a.b}}')({ a: {} }), + ).toThrowErrorMatchingInlineSnapshot(`"No value for selector 'a.b'"`); + }); + + it('just passes down broken parts of patterns', () => { + expect(createPatternResolver('-{{a}}-}}')({ a: 1 })).toEqual('-1-}}'); + expect(createPatternResolver('{{-{{a}}-')({ a: 1 })).toEqual('{{-1-'); + expect(createPatternResolver('{{-{{a}}-}}')({ a: 1 })).toEqual('{{-1-}}'); + expect(createPatternResolver('{{-{{}}-}}')({ a: 1 })).toEqual('{{--}}'); + }); +}); diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.ts new file mode 100644 index 0000000000..b79b8feaeb --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/config.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2025 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 { RootConfigService } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { InputError } from '@backstage/errors'; +import { Message } from '@google-cloud/pubsub'; +import { SubscriptionTask } from './types'; +import lodash from 'lodash'; + +export function readSubscriptionTasksFromConfig( + rootConfig: RootConfigService, +): SubscriptionTask[] { + const subscriptionsConfig = rootConfig.getOptionalConfig( + 'events.modules.googlePubSub.googlePubSubConsumingEventPublisher.subscriptions', + ); + if (!subscriptionsConfig) { + return []; + } + + return subscriptionsConfig.keys().map(subscriptionId => { + if (!subscriptionId.match(/^[-_\w]+$/)) { + throw new InputError( + `Expected Googoe Pub/Sub subscription ID to consist of letters, numbers, dashes and lodashes, but got '${subscriptionId}'`, + ); + } + + const config = subscriptionsConfig.getConfig(subscriptionId); + const { project, subscription } = readSubscriptionName(config); + const mapToTopic = readTopicMapper(config); + const mapToMetadata = readMetadataMapper(config); + + return { + id: subscriptionId, + project, + subscription, + mapToTopic, + mapToMetadata, + }; + }); +} + +function readSubscriptionName(config: Config): { + project: string; + subscription: string; +} { + const subscriptionName = config.getString('subscriptionName'); + const parts = subscriptionName.match( + /^projects\/([^/]+)\/subscriptions\/(.+)$/, + ); + if (!parts) { + throw new InputError( + `Expected Googoe Pub/Sub 'subscriptionName' to be on the form 'projects/PROJECT_ID/subscriptions/SUBSCRIPTION_ID' but got '${subscriptionName}'`, + ); + } + return { + project: parts[1], + subscription: parts[2], + }; +} + +/** + * Handles the `targetTopic` configuration field. + */ +function readTopicMapper( + config: Config, +): (message: Message) => string | undefined { + const targetTopicPattern = config.getString('targetTopic'); + const patternResolver = createPatternResolver(targetTopicPattern); + return message => { + try { + return patternResolver({ message }); + } catch { + // could not map to a topic + return undefined; + } + }; +} + +/** + * Handles the `eventMetadata` configuration field. + */ +function readMetadataMapper( + config: Config, +): (message: Message) => Record { + const setters = new Array< + (options: { message: Message; metadata: Record }) => void + >(); + + const eventMetadata = config.getOptionalConfig('eventMetadata'); + if (eventMetadata) { + for (const key of eventMetadata?.keys() ?? []) { + const valuePattern = eventMetadata.getString(key); + const patternResolver = createPatternResolver(valuePattern); + setters.push(({ message, metadata }) => { + try { + const value = patternResolver({ message }); + if (value) { + metadata[key] = value; + } + } catch { + // ignore silently, keep original unchanged + } + }); + } + } + + return message => { + const result: Record = { + ...message.attributes, + }; + for (const setter of setters) { + setter({ message, metadata: result }); + } + return result; + }; +} + +/** + * Takes a pattern string that may contain `{{ path.to.value }}` placeholders, + * and returns a function that accepts a context object and returns strings that + * have had its placeholders filled in by using `lodash.get` accordingly on the + * context. + */ +export function createPatternResolver( + pattern: string, +): (context: unknown) => string { + // This split results in an array where even elements are static strings + // between placeholders, and odd elements are the contents inside + // placeholders. + // + // For example, the pattern: + // "{{ foo }}-{{bar}}{{baz}}." + // will result in: + // ['', 'foo', '-', 'bar', '', 'baz', .'] + const patternParts = pattern.split(/\{{\s*([\w\[\]'_.-]*)\s*}}/g); + + const resolvers = new Array<(context: unknown) => string>(); + + for (let i = 0; i < patternParts.length; i += 2) { + const staticPart = patternParts[i]; + const placeholderPart = patternParts[i + 1]; + + if (staticPart) { + resolvers.push(() => staticPart); + } + + if (placeholderPart) { + resolvers.push(context => { + const value = lodash.get(context, placeholderPart); + if (typeof value === 'string' || Number.isFinite(value)) { + return String(value); + } else if (!value) { + throw new InputError(`No value for selector '${placeholderPart}'`); + } else { + throw new InputError( + `Expected string or number value for selector '${placeholderPart}', got ${typeof value}`, + ); + } + }); + } + } + + return context => resolvers.map(resolver => resolver(context)).join(''); +} diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/index.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/index.ts new file mode 100644 index 0000000000..90b0181da6 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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 { eventsModuleGooglePubsubConsumingEventPublisher } from './module'; diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/module.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/module.ts new file mode 100644 index 0000000000..14cfdd2bd8 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/module.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { GooglePubSubConsumingEventPublisher } from './GooglePubSubConsumingEventPublisher'; + +/** + * Reads messages off of Google Pub/Sub subscriptions and forwards them into the + * Backstage events system. + * + * @public + */ +export const eventsModuleGooglePubsubConsumingEventPublisher = + createBackendModule({ + pluginId: 'events', + moduleId: 'google-pubsub-consuming-event-publisher', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + logger: coreServices.logger, + rootLifecycle: coreServices.rootLifecycle, + events: eventsServiceRef, + }, + async init({ config, logger, rootLifecycle, events }) { + GooglePubSubConsumingEventPublisher.create({ + config, + logger, + rootLifecycle, + events, + }); + }, + }); + }, + }); diff --git a/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/types.ts b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/types.ts new file mode 100644 index 0000000000..08c1e11999 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/GooglePubSubConsumingEventPublisher/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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 { Message } from '@google-cloud/pubsub'; + +/** + * A configured subscription task. + */ +export interface SubscriptionTask { + id: string; + project: string; + subscription: string; + mapToTopic: (message: Message) => string | undefined; + mapToMetadata: (message: Message) => Record; +} diff --git a/plugins/events-backend-module-google-pubsub/src/index.ts b/plugins/events-backend-module-google-pubsub/src/index.ts new file mode 100644 index 0000000000..2b79fcb9a1 --- /dev/null +++ b/plugins/events-backend-module-google-pubsub/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2025 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. + */ + +/** + * The google-pubsub backend module for the events plugin. + * + * @packageDocumentation + */ + +export { eventsModuleGooglePubsubConsumingEventPublisher as default } from './GooglePubSubConsumingEventPublisher'; diff --git a/yarn.lock b/yarn.lock index 01cdf6283d..340c3fe16e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6384,6 +6384,26 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-events-backend-module-google-pubsub@workspace:^, @backstage/plugin-events-backend-module-google-pubsub@workspace:plugins/events-backend-module-google-pubsub": + version: 0.0.0-use.local + resolution: "@backstage/plugin-events-backend-module-google-pubsub@workspace:plugins/events-backend-module-google-pubsub" + dependencies: + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-events-backend": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" + "@backstage/types": "workspace:^" + "@google-cloud/pubsub": "npm:^4.10.0" + "@opentelemetry/api": "npm:^1.9.0" + lodash: "npm:^4.17.21" + wait-for-expect: "npm:^3.0.2" + languageName: unknown + linkType: soft + "@backstage/plugin-events-backend-test-utils@workspace:^, @backstage/plugin-events-backend-test-utils@workspace:plugins/events-backend-test-utils": version: 0.0.0-use.local resolution: "@backstage/plugin-events-backend-test-utils@workspace:plugins/events-backend-test-utils" @@ -9965,6 +9985,13 @@ __metadata: languageName: node linkType: hard +"@google-cloud/precise-date@npm:^4.0.0": + version: 4.0.0 + resolution: "@google-cloud/precise-date@npm:4.0.0" + checksum: 10/7c897bcad6a40efd77df165fca8a57bd3fbb84e6ace848085b16233c1086c32c06258caa88b9c9aaf7d4029ded13d01b57d1b5fd808dc5722cf57626ac4a386b + languageName: node + linkType: hard + "@google-cloud/projectify@npm:^4.0.0": version: 4.0.0 resolution: "@google-cloud/projectify@npm:4.0.0" @@ -9972,13 +9999,35 @@ __metadata: languageName: node linkType: hard -"@google-cloud/promisify@npm:^4.0.0": +"@google-cloud/promisify@npm:^4.0.0, @google-cloud/promisify@npm:~4.0.0": version: 4.0.0 resolution: "@google-cloud/promisify@npm:4.0.0" checksum: 10/c5de81321b3a5c567edcbe0b941fb32644611147f3ba22f20575918c225a979988a99bc2ebda05ac914fa8714b0a54c69be72c3f46c7a64c3b19db7d7fba8d04 languageName: node linkType: hard +"@google-cloud/pubsub@npm:^4.10.0": + version: 4.11.0 + resolution: "@google-cloud/pubsub@npm:4.11.0" + dependencies: + "@google-cloud/paginator": "npm:^5.0.0" + "@google-cloud/precise-date": "npm:^4.0.0" + "@google-cloud/projectify": "npm:^4.0.0" + "@google-cloud/promisify": "npm:~4.0.0" + "@opentelemetry/api": "npm:~1.9.0" + "@opentelemetry/semantic-conventions": "npm:~1.30.0" + arrify: "npm:^2.0.0" + extend: "npm:^3.0.2" + google-auth-library: "npm:^9.3.0" + google-gax: "npm:^4.3.3" + heap-js: "npm:^2.2.0" + is-stream-ended: "npm:^0.1.4" + lodash.snakecase: "npm:^4.1.1" + p-defer: "npm:^3.0.0" + checksum: 10/bfaae6b0ca25d9adf2ba160850ae3db0481c7a45cd4107ec7b5afd5002845789e1effecaaeb6fe03ddf302060e7f2da55e9ff0c3dc3a590adbe73335369fae75 + languageName: node + linkType: hard + "@google-cloud/storage@npm:^7.0.0": version: 7.14.0 resolution: "@google-cloud/storage@npm:7.14.0" @@ -13402,7 +13451,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.0": +"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.0, @opentelemetry/api@npm:~1.9.0": version: 1.9.0 resolution: "@opentelemetry/api@npm:1.9.0" checksum: 10/a607f0eef971893c4f2ee2a4c2069aade6ec3e84e2a1f5c2aac19f65c5d9eeea41aa72db917c1029faafdd71789a1a040bdc18f40d63690e22ccae5d7070f194 @@ -14689,7 +14738,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.27.0": +"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:~1.30.0": version: 1.30.0 resolution: "@opentelemetry/semantic-conventions@npm:1.30.0" checksum: 10/78df5976f5bcfd00acaea3e609cf06fdd34517ae8db994ae216aaac16c51af97ac22c534bfcbac5218e0086db83ec5ef6cc045b95626cc6ea807686bea549a41 @@ -29150,6 +29199,7 @@ __metadata: "@backstage/plugin-catalog-backend-module-unprocessed": "workspace:^" "@backstage/plugin-devtools-backend": "workspace:^" "@backstage/plugin-events-backend": "workspace:^" + "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^" "@backstage/plugin-kubernetes-backend": "workspace:^" "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" @@ -31435,6 +31485,13 @@ __metadata: languageName: node linkType: hard +"heap-js@npm:^2.2.0": + version: 2.6.0 + resolution: "heap-js@npm:2.6.0" + checksum: 10/33c4e9a82b8698856cf807845866e5b3e94e2c4898937a99a160aea698c68fbe121f0bffc514af8d71d32eae36ca9516063c7e13d3624457318d5cf246a623a9 + languageName: node + linkType: hard + "helmet@npm:^6.0.0": version: 6.0.1 resolution: "helmet@npm:6.0.1" @@ -32943,6 +33000,13 @@ __metadata: languageName: node linkType: hard +"is-stream-ended@npm:^0.1.4": + version: 0.1.4 + resolution: "is-stream-ended@npm:0.1.4" + checksum: 10/56cbc9cfa0a77877777a3df9e186abb5b0ca73dcbcaf0fd87ed573fb8f8e61283abec0fc072c9e3412336edc04449439b8a128d2bcc6c2797158de5465cfaf85 + languageName: node + linkType: hard + "is-stream@npm:^1.1.0": version: 1.1.0 resolution: "is-stream@npm:1.1.0" @@ -35493,6 +35557,13 @@ __metadata: languageName: node linkType: hard +"lodash.snakecase@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.snakecase@npm:4.1.1" + checksum: 10/82ed40935d840477ef8fee64f9f263f75989c6cde36b84aae817246d95826228e1b5a7f6093c51de324084f86433634c7af244cb89496633cacfe443071450d0 + languageName: node + linkType: hard + "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0" @@ -38767,6 +38838,13 @@ __metadata: languageName: node linkType: hard +"p-defer@npm:^3.0.0": + version: 3.0.0 + resolution: "p-defer@npm:3.0.0" + checksum: 10/ac3b0976a1c76b67cca1a34e00f7299b0cc230891f820749686aa84f8947326bbe0f8e3b7d9ca511578ee06f0c1a6e0ff68c8e9c325eac455f09d99f91697161 + languageName: node + linkType: hard + "p-filter@npm:^2.1.0": version: 2.1.0 resolution: "p-filter@npm:2.1.0"