diff --git a/.changeset/rare-comics-tan.md b/.changeset/rare-comics-tan.md
new file mode 100644
index 0000000000..338dfba7c2
--- /dev/null
+++ b/.changeset/rare-comics-tan.md
@@ -0,0 +1,11 @@
+---
+'@backstage/backend-common': patch
+---
+
+Added a `Context` type for the backend, that can propagate an abort signal, a
+deadline, and contextual values through the call stack. The main entrypoint is
+the `Contexts` utility class that provides a root context creator and commonly
+used decorators.
+
+These are marked as `@alpha` for now, and are therefore only accessible via
+`@backstage/backend-common/alpha`.
diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md
index d63416571d..f53fb9d6e4 100644
--- a/packages/backend-common/api-report.md
+++ b/packages/backend-common/api-report.md
@@ -6,6 +6,7 @@
///
///
+import { AbortController as AbortController_2 } from 'node-abort-controller';
import { AbortSignal as AbortSignal_2 } from 'node-abort-controller';
import { AwsS3Integration } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
@@ -13,6 +14,7 @@ import { BitbucketIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import cors from 'cors';
import Docker from 'dockerode';
+import { Duration } from 'luxon';
import { ErrorRequestHandler } from 'express';
import express from 'express';
import { GithubCredentialsProvider } from '@backstage/integration';
@@ -145,6 +147,29 @@ export interface ContainerRunner {
runContainer(opts: RunContainerOptions): Promise;
}
+// @alpha
+export interface Context {
+ readonly abortSignal: AbortSignal_2;
+ readonly deadline: Date | undefined;
+ value(key: string): T | undefined;
+}
+
+// @alpha
+export class Contexts {
+ static root(): Context;
+ static withAbort(
+ parentCtx: Context,
+ source: AbortController_2 | AbortSignal_2,
+ ): Context;
+ static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context;
+ static withTimeoutMillis(parentCtx: Context, timeout: number): Context;
+ static withValue(
+ parentCtx: Context,
+ key: string,
+ value: unknown | ((previous: unknown | undefined) => unknown),
+ ): Context;
+}
+
// @public @deprecated
export const createDatabase: typeof createDatabaseClient;
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index b8f56c1fbe..f8e445465e 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -8,7 +8,8 @@
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
- "types": "dist/index.d.ts"
+ "types": "dist/index.d.ts",
+ "alphaTypes": "dist/index.alpha.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
@@ -21,7 +22,7 @@
],
"license": "Apache-2.0",
"scripts": {
- "build": "backstage-cli build --outputs cjs,types",
+ "build": "backstage-cli build --experimental-type-build --outputs cjs,types",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
@@ -41,6 +42,7 @@
"@types/cors": "^2.8.6",
"@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
+ "@types/luxon": "^2.0.4",
"archiver": "^5.0.2",
"aws-sdk": "^2.840.0",
"compression": "^1.7.4",
@@ -59,6 +61,7 @@
"knex": "^0.95.1",
"lodash": "^4.17.21",
"logform": "^2.3.2",
+ "luxon": "^2.0.2",
"minimatch": "^3.0.4",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
@@ -109,7 +112,8 @@
},
"files": [
"dist",
- "config.d.ts"
+ "config.d.ts",
+ "alpha"
],
"configSchema": "config.d.ts"
}
diff --git a/packages/backend-common/src/context/AbortContext.test.ts b/packages/backend-common/src/context/AbortContext.test.ts
new file mode 100644
index 0000000000..f47e1d4beb
--- /dev/null
+++ b/packages/backend-common/src/context/AbortContext.test.ts
@@ -0,0 +1,345 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AbortController } from 'node-abort-controller';
+import { AbortContext } from './AbortContext';
+import { RootContext } from './RootContext';
+
+describe('AbortContext', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('forTimeoutMillis', () => {
+ it('can abort on a timeout', async () => {
+ jest.useFakeTimers();
+ const timeout = 200;
+ const deadline = Date.now() + timeout;
+
+ const root = new RootContext();
+
+ const child = AbortContext.forTimeoutMillis(root, timeout);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(Math.abs(+child.deadline! - deadline)).toBeLessThan(50);
+ expect(childListener).toBeCalledTimes(0);
+
+ jest.advanceTimersByTime(timeout + 1);
+
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('results in minimum deadline when parent triggers sooner', async () => {
+ jest.useFakeTimers();
+ const parentTimeout = 200;
+ const childTimeout = 300;
+ const parentDeadline = Date.now() + parentTimeout;
+ const childDeadline = parentDeadline; // clamped
+
+ const root = new RootContext();
+
+ const parent = AbortContext.forTimeoutMillis(root, parentTimeout);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const child = AbortContext.forTimeoutMillis(parent, childTimeout);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50);
+ expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ jest.advanceTimersByTime(parentTimeout + 1);
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(1);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('results in minimum deadline when child triggers sooner', async () => {
+ jest.useFakeTimers();
+ const parentTimeout = 300;
+ const childTimeout = 200;
+ const parentDeadline = Date.now() + parentTimeout;
+ const childDeadline = Date.now() + childTimeout;
+
+ const root = new RootContext();
+
+ const parent = AbortContext.forTimeoutMillis(root, parentTimeout);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const child = AbortContext.forTimeoutMillis(parent, childTimeout);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50);
+ expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ jest.advanceTimersByTime(childTimeout + 1);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(1);
+
+ jest.advanceTimersByTime(parentTimeout - childTimeout + 1);
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(1);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('child carries over parent signal state if parent was already aborted and had no deadline', async () => {
+ jest.useFakeTimers();
+ const childTimeout = 200;
+ const childDeadline = Date.now() + childTimeout;
+
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forSignal(root, parentController.signal);
+
+ parentController.abort();
+
+ const child = AbortContext.forTimeoutMillis(parent, childTimeout);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+ expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
+
+ jest.advanceTimersByTime(childTimeout + 1);
+
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0); // still
+ });
+
+ it('child carries over parent signal state if parent was already aborted and had a deadline', async () => {
+ jest.useFakeTimers();
+ const first = new RootContext();
+
+ const secondController = new AbortController();
+ const second = AbortContext.forSignal(first, secondController.signal);
+ secondController.abort();
+
+ const third = AbortContext.forTimeoutMillis(second, 200);
+ const fourth = AbortContext.forTimeoutMillis(third, 300);
+
+ expect(third.abortSignal.aborted).toBe(true);
+ expect(fourth.abortSignal.aborted).toBe(true);
+ expect(Math.abs(+fourth.deadline! - Date.now() - 200)).toBeLessThan(50);
+ });
+ });
+
+ describe('forController', () => {
+ it('signals child when parent is aborted', () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forController(root, parentController);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const childController = new AbortController();
+ const child = AbortContext.forController(parent, childController);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ parentController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(1);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('does not signal parent when child is aborted', async () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forController(root, parentController);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const childController = new AbortController();
+ const child = AbortContext.forController(parent, childController);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ childController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('child carries over parent signal state if parent was already aborted', async () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forController(root, parentController);
+
+ parentController.abort();
+
+ const childController = new AbortController();
+ const child = AbortContext.forController(parent, childController);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+
+ childController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+ });
+
+ it('child carries over given signal state if it was already aborted', async () => {
+ const root = new RootContext();
+
+ const childController = new AbortController();
+ childController.abort();
+
+ const child = AbortContext.forController(root, childController);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+ });
+ });
+
+ describe('forSignal', () => {
+ it('signals child when parent is aborted', async () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forSignal(root, parentController.signal);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const childController = new AbortController();
+ const child = AbortContext.forSignal(parent, childController.signal);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ parentController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(1);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('does not signal parent when child is aborted', async () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forSignal(root, parentController.signal);
+ const parentListener = jest.fn();
+ parent.abortSignal.addEventListener('abort', parentListener);
+
+ const childController = new AbortController();
+ const child = AbortContext.forSignal(parent, childController.signal);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(false);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(0);
+
+ childController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(false);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(parentListener).toBeCalledTimes(0);
+ expect(childListener).toBeCalledTimes(1);
+ });
+
+ it('child carries over parent signal state if parent was already aborted', async () => {
+ const root = new RootContext();
+
+ const parentController = new AbortController();
+ const parent = AbortContext.forSignal(root, parentController.signal);
+
+ parentController.abort();
+
+ const childController = new AbortController();
+ const child = AbortContext.forSignal(parent, childController.signal);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+
+ childController.abort();
+
+ expect(parent.abortSignal.aborted).toBe(true);
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+ });
+
+ it('child carries over given signal state if it was already aborted', async () => {
+ const root = new RootContext();
+
+ const childController = new AbortController();
+ childController.abort();
+
+ const child = AbortContext.forSignal(root, childController.signal);
+ const childListener = jest.fn();
+ child.abortSignal.addEventListener('abort', childListener);
+
+ expect(child.abortSignal.aborted).toBe(true);
+ expect(childListener).toBeCalledTimes(0);
+ });
+ });
+});
diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts
new file mode 100644
index 0000000000..8a119358a6
--- /dev/null
+++ b/packages/backend-common/src/context/AbortContext.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AbortController, AbortSignal } from 'node-abort-controller';
+import { Context } from './types';
+
+/**
+ * A context that implements various abort related functionality.
+ */
+export class AbortContext implements Context {
+ /**
+ * Abort either when the parent aborts, or after the given timeout has
+ * expired.
+ *
+ * @param ctx - The parent context
+ * @param timeout - A timeout value, in milliseconds
+ * @returns A new context
+ */
+ static forTimeoutMillis(ctx: Context, timeout: number): Context {
+ const desiredDeadline = new Date(Date.now() + timeout);
+ const actualDeadline =
+ ctx.deadline && ctx.deadline < desiredDeadline
+ ? ctx.deadline
+ : desiredDeadline;
+
+ if (ctx.abortSignal.aborted) {
+ if (ctx.deadline && desiredDeadline === actualDeadline) {
+ return ctx;
+ }
+ return new AbortContext(ctx, ctx.abortSignal, actualDeadline);
+ }
+
+ const controller = new AbortController();
+ const timeoutHandle = setTimeout(abort, timeout);
+ ctx.abortSignal.addEventListener('abort', abort);
+
+ function abort() {
+ ctx.abortSignal.removeEventListener('abort', abort);
+ clearTimeout(timeoutHandle!);
+ controller.abort();
+ }
+
+ return new AbortContext(ctx, controller.signal, actualDeadline);
+ }
+
+ /**
+ * Abort either when the parent aborts, or when the given controller is
+ * triggered.
+ *
+ * @remarks
+ *
+ * If you have access to the controller, this function is more efficient than
+ * {@link AbortContext#forSignal}.
+ *
+ * @param ctx - The parent context
+ * @param controller - An abort controller
+ * @returns A new context
+ */
+ static forController(ctx: Context, controller: AbortController): Context {
+ // Already aborted context / signal are fine to reuse as-is
+ if (ctx.abortSignal.aborted) {
+ return ctx;
+ } else if (controller.signal.aborted) {
+ return new AbortContext(ctx, controller.signal, ctx.deadline);
+ }
+
+ function abort() {
+ ctx.abortSignal.removeEventListener('abort', abort);
+ controller.abort();
+ }
+
+ ctx.abortSignal.addEventListener('abort', abort);
+
+ return new AbortContext(ctx, controller.signal, ctx.deadline);
+ }
+
+ /**
+ * Abort either when the parent aborts, or when the given signal is triggered.
+ *
+ * @remarks
+ *
+ * If you have access to the controller and not just the signal,
+ * {@link AbortContext#forController} is slightly more efficient to use.
+ *
+ * @param ctx - The parent context
+ * @param signal - An abort signal
+ * @returns A new context
+ */
+ static forSignal(ctx: Context, signal: AbortSignal): Context {
+ // Already aborted context / signal are fine to reuse as-is
+ if (ctx.abortSignal.aborted) {
+ return ctx;
+ } else if (signal.aborted) {
+ return new AbortContext(ctx, signal, ctx.deadline);
+ }
+
+ const controller = new AbortController();
+
+ function abort() {
+ ctx.abortSignal.removeEventListener('abort', abort);
+ signal.removeEventListener('abort', abort);
+ controller.abort();
+ }
+
+ ctx.abortSignal.addEventListener('abort', abort);
+ signal.addEventListener('abort', abort);
+
+ return new AbortContext(ctx, controller.signal, ctx.deadline);
+ }
+
+ private constructor(
+ private readonly parent: Context,
+ readonly abortSignal: AbortSignal,
+ readonly deadline: Date | undefined,
+ ) {}
+
+ value(key: string): T | undefined {
+ return this.parent.value(key);
+ }
+}
diff --git a/packages/backend-common/src/context/Contexts.test.ts b/packages/backend-common/src/context/Contexts.test.ts
new file mode 100644
index 0000000000..cbd61d20a9
--- /dev/null
+++ b/packages/backend-common/src/context/Contexts.test.ts
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Duration } from 'luxon';
+import { AbortController } from 'node-abort-controller';
+import { Contexts } from './Contexts';
+
+describe('Contexts', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('root', () => {
+ it('can create a root', () => {
+ const ctx = Contexts.root();
+ expect(ctx.abortSignal).toBeDefined();
+ expect(ctx.deadline).toBeUndefined();
+ });
+ });
+
+ describe('setAbort', () => {
+ it('works for controllers', () => {
+ const controller = new AbortController();
+ const parent = Contexts.root();
+ const child = Contexts.withAbort(parent, controller);
+ expect(child.abortSignal.aborted).toBe(false);
+ controller.abort();
+ expect(child.abortSignal.aborted).toBe(true);
+ });
+
+ it('works for signals', () => {
+ const controller = new AbortController();
+ const parent = Contexts.root();
+ const child = Contexts.withAbort(parent, controller.signal);
+ expect(child.abortSignal.aborted).toBe(false);
+ controller.abort();
+ expect(child.abortSignal.aborted).toBe(true);
+ });
+ });
+
+ describe('setTimeoutDuration', () => {
+ it('works', () => {
+ jest.useFakeTimers();
+ const parent = Contexts.root();
+ const child = Contexts.withTimeoutDuration(
+ parent,
+ Duration.fromMillis(200),
+ );
+ expect(child.abortSignal.aborted).toBe(false);
+ jest.advanceTimersByTime(100);
+ expect(child.abortSignal.aborted).toBe(false);
+ jest.advanceTimersByTime(101);
+ expect(child.abortSignal.aborted).toBe(true);
+ });
+ });
+
+ describe('setTimeoutMillis', () => {
+ it('works', () => {
+ jest.useFakeTimers();
+ const parent = Contexts.root();
+ const child = Contexts.withTimeoutMillis(parent, 200);
+ expect(child.abortSignal.aborted).toBe(false);
+ jest.advanceTimersByTime(100);
+ expect(child.abortSignal.aborted).toBe(false);
+ jest.advanceTimersByTime(101);
+ expect(child.abortSignal.aborted).toBe(true);
+ });
+ });
+
+ describe('setValue', () => {
+ it('works', () => {
+ const parent = Contexts.root();
+ const child = Contexts.withValue(parent, 'k', 'v');
+ expect(child.value('k')).toBe('v');
+ });
+ });
+});
diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts
new file mode 100644
index 0000000000..e22a76f673
--- /dev/null
+++ b/packages/backend-common/src/context/Contexts.ts
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Duration } from 'luxon';
+import { AbortController, AbortSignal } from 'node-abort-controller';
+import { AbortContext } from './AbortContext';
+import { RootContext } from './RootContext';
+import { Context } from './types';
+import { ValueContext } from './ValueContext';
+
+/**
+ * Common context decorators.
+ *
+ * @alpha
+ */
+export class Contexts {
+ /**
+ * Creates a root context.
+ *
+ * @remarks
+ *
+ * This should normally only be called near the root of an application. The
+ * created context is meant to be passed down into deeper levels, which may or
+ * may not make derived contexts out of it.
+ */
+ static root(): Context {
+ return new RootContext();
+ }
+
+ /**
+ * Creates a derived context, which signals to abort operations either when
+ * any parent context signals, or when the given source is aborted.
+ *
+ * @remarks
+ *
+ * If the parent context was already aborted, then it is returned as-is.
+ *
+ * If the given source was already aborted, then a new already-aborted context
+ * is returned.
+ *
+ * @param parentCtx - A parent context that shall be used as a base
+ * @param source - An abort controller or signal that you intend to perhaps
+ * trigger at some later point in time.
+ * @returns A new {@link Context}
+ */
+ static withAbort(
+ parentCtx: Context,
+ source: AbortController | AbortSignal,
+ ): Context {
+ return 'aborted' in source
+ ? AbortContext.forSignal(parentCtx, source)
+ : AbortContext.forController(parentCtx, source);
+ }
+
+ /**
+ * Creates a derived context, which signals to abort operations either when
+ * any parent context signals, or when the given amount of time has passed.
+ * This may affect the deadline.
+ *
+ * @param parentCtx - A parent context that shall be used as a base
+ * @param timeout - The duration of time, after which the derived context will
+ * signal to abort.
+ * @returns A new {@link Context}
+ */
+ static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context {
+ return AbortContext.forTimeoutMillis(parentCtx, timeout.as('milliseconds'));
+ }
+
+ /**
+ * Creates a derived context, which signals to abort operations either when
+ * any parent context signals, or when the given amount of time has passed.
+ * This may affect the deadline.
+ *
+ * @param parentCtx - A parent context that shall be used as a base
+ * @param timeout - The number of milliseconds, after which the derived
+ * context will signal to abort.
+ * @returns A new {@link Context}
+ */
+ static withTimeoutMillis(parentCtx: Context, timeout: number): Context {
+ return AbortContext.forTimeoutMillis(parentCtx, timeout);
+ }
+
+ /**
+ * Creates a derived context, which has a specific key-value pair set as well
+ * as all key-value pairs that were set in the original context.
+ *
+ * @param parentCtx - A parent context that shall be used as a base
+ * @param key - The key of the value to set
+ * @param value - The value, or a function that accepts the previous value (or
+ * undefined if not set yet) and computes the new value
+ * @returns A new {@link Context}
+ */
+ static withValue(
+ parentCtx: Context,
+ key: string,
+ value: unknown | ((previous: unknown | undefined) => unknown),
+ ): Context {
+ const v = typeof value === 'function' ? value(parentCtx.value(key)) : value;
+ return ValueContext.forConstantValue(parentCtx, key, v);
+ }
+}
diff --git a/packages/backend-common/src/context/RootContext.test.ts b/packages/backend-common/src/context/RootContext.test.ts
new file mode 100644
index 0000000000..395786e3dc
--- /dev/null
+++ b/packages/backend-common/src/context/RootContext.test.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RootContext } from './RootContext';
+
+describe('RootContext', () => {
+ it('returns empty values', async () => {
+ const ctx = new RootContext();
+ expect(ctx.abortSignal).toBeDefined();
+ expect(ctx.abortSignal.aborted).toBe(false);
+ expect(ctx.deadline).toBeUndefined();
+ expect(ctx.value('a')).toBeUndefined();
+ });
+});
diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts
new file mode 100644
index 0000000000..61962380a4
--- /dev/null
+++ b/packages/backend-common/src/context/RootContext.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AbortSignal } from 'node-abort-controller';
+import { Context } from './types';
+
+/**
+ * Since the root context can never abort, and since nobody is every meant to
+ * dispatch events through it, we can use a static dummy instance for
+ * efficiency.
+ */
+const dummyAbortSignal: AbortSignal = Object.freeze({
+ aborted: false,
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() {
+ return true;
+ },
+ onabort: null,
+});
+
+/**
+ * An empty root context.
+ */
+export class RootContext implements Context {
+ readonly abortSignal = dummyAbortSignal;
+ readonly deadline = undefined;
+
+ value(_key: string): T | undefined {
+ return undefined;
+ }
+}
diff --git a/packages/backend-common/src/context/ValueContext.test.ts b/packages/backend-common/src/context/ValueContext.test.ts
new file mode 100644
index 0000000000..6a216d4548
--- /dev/null
+++ b/packages/backend-common/src/context/ValueContext.test.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RootContext } from './RootContext';
+import { ValueContext } from './ValueContext';
+
+describe('ValueContext', () => {
+ it('returns its own values, or delegates to the parent', async () => {
+ const root = new RootContext();
+ const a = ValueContext.forConstantValue(root, 'a', 1);
+ const b = ValueContext.forConstantValue(a, 'x', 2);
+ const c = ValueContext.forConstantValue(b, 'a', 3);
+ const d = ValueContext.forConstantValue(c, 'b', 4);
+
+ expect(a.value('a')).toBe(1);
+ expect(a.value('b')).toBeUndefined();
+ expect(a.value('x')).toBeUndefined();
+
+ expect(b.value('a')).toBe(1);
+ expect(b.value('b')).toBeUndefined();
+ expect(b.value('x')).toBe(2);
+
+ expect(c.value('a')).toBe(3);
+ expect(c.value('b')).toBeUndefined();
+ expect(c.value('x')).toBe(2);
+
+ expect(d.value('a')).toBe(3);
+ expect(d.value('b')).toBe(4);
+ expect(d.value('x')).toBe(2);
+ });
+});
diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts
new file mode 100644
index 0000000000..441aaec984
--- /dev/null
+++ b/packages/backend-common/src/context/ValueContext.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AbortSignal } from 'node-abort-controller';
+import { Context } from './types';
+
+/**
+ * A context that just holds a single value, and delegates the rest to its
+ * parent.
+ */
+export class ValueContext implements Context {
+ static forConstantValue(ctx: Context, key: string, value: unknown): Context {
+ return new ValueContext(ctx, key, value);
+ }
+
+ constructor(
+ private readonly _parent: Context,
+ private readonly _key: string,
+ private readonly _value: unknown,
+ ) {}
+
+ get abortSignal(): AbortSignal {
+ return this._parent.abortSignal;
+ }
+
+ get deadline(): Date | undefined {
+ return this._parent.deadline;
+ }
+
+ value(key: string): T | undefined {
+ return key === this._key ? (this._value as T) : this._parent.value(key);
+ }
+}
diff --git a/packages/backend-common/src/context/index.ts b/packages/backend-common/src/context/index.ts
new file mode 100644
index 0000000000..37a6e29c8c
--- /dev/null
+++ b/packages/backend-common/src/context/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { Contexts } from './Contexts';
+export type { Context } from './types';
diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts
new file mode 100644
index 0000000000..664745bbf6
--- /dev/null
+++ b/packages/backend-common/src/context/types.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AbortSignal } from 'node-abort-controller';
+
+/**
+ * A context that is meant to be passed as a ctx variable down the call chain,
+ * to pass along scoped information and abort signals.
+ *
+ * @alpha
+ */
+export interface Context {
+ /**
+ * Returns an abort signal that triggers when the current context or any of
+ * its parents signal for it.
+ */
+ readonly abortSignal: AbortSignal;
+
+ /**
+ * The point in time when the current context shall time out and abort, if
+ * applicable.
+ */
+ readonly deadline: Date | undefined;
+
+ /**
+ * Attempts to get a stored value by key from the context.
+ *
+ * @param key - The key of the value to get
+ * @returns The associated value, or undefined if not set
+ */
+ value(key: string): T | undefined;
+}
diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts
index e430ddc1a4..238e58b2a8 100644
--- a/packages/backend-common/src/index.ts
+++ b/packages/backend-common/src/index.ts
@@ -22,6 +22,7 @@
export * from './cache';
export { loadBackendConfig } from './config';
+export * from './context';
export * from './database';
export * from './discovery';
export * from './hot';