Merge pull request #8042 from backstage/freben/backend-context
[PRFC] Introduce the backend-common Context
This commit is contained in:
@@ -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`.
|
||||
@@ -6,6 +6,7 @@
|
||||
/// <reference types="node" />
|
||||
/// <reference types="webpack-env" />
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface Context {
|
||||
readonly abortSignal: AbortSignal_2;
|
||||
readonly deadline: Date | undefined;
|
||||
value<T = unknown>(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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T = unknown>(key: string): T | undefined {
|
||||
return this.parent.value(key);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<T = unknown>(_key: string): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<T = unknown>(key: string): T | undefined {
|
||||
return key === this._key ? (this._value as T) : this._parent.value(key);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<T = unknown>(key: string): T | undefined;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
export * from './cache';
|
||||
export { loadBackendConfig } from './config';
|
||||
export * from './context';
|
||||
export * from './database';
|
||||
export * from './discovery';
|
||||
export * from './hot';
|
||||
|
||||
Reference in New Issue
Block a user