Skip to content

Commit ee0b5e5

Browse files
committed
Add backward-compatible support for abort
1 parent 31bdc44 commit ee0b5e5

File tree

4 files changed

+202
-31
lines changed

4 files changed

+202
-31
lines changed

README.md

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
> Tiny, type-safe, JavaScript-native `context` implementation.
1010
11-
**Why?** Working on a project across browsers, serverless and node.js requires different implementations on the same thing, e.g. `fetch` vs `require('http')`. Go's [`context`](https://blog.golang.org/context) package provides a nice abstraction to bring all the interfaces together. By implementing a JavaScript first variation, we can achieve the same benefits.
11+
**Why?** Working on a project across browsers, workers and node.js requires different implementations on the same thing, e.g. `fetch` vs `require('http')`. Go's [`context`](https://blog.golang.org/context) package provides a nice abstraction to bring all the interfaces together. By implementing a JavaScript first variation, we can achieve the same benefits.
1212

1313
## Installation
1414

@@ -23,16 +23,65 @@ Context values are unidirectional.
2323
```ts
2424
import { background, withValue } from "@borderless/context";
2525

26-
const defaultContext = background;
27-
const anotherContext = withValue(defaultContext, "test", "test");
26+
// Extend the default `background` context with a value.
27+
const ctx = withValue(background, "test", "test");
2828

29-
anotherContext.value("test"); //=> "test"
30-
defaultContext.value("test"); // Invalid.
29+
ctx.value("test"); //=> "test"
30+
background.value("test"); // Invalid.
31+
```
32+
33+
### Abort
34+
35+
Use `withAbort` to support cancellation of execution in your application.
36+
37+
```ts
38+
import { withAbort } from "@borderless/context";
39+
40+
const [ctx, abort] = withAbort(parentCtx);
41+
42+
onUserCancelsTask(() => abort(new Error("User canceled task")));
43+
```
44+
45+
### Timeout
46+
47+
Use `withTimeout` when you want to abort after a specific duration:
48+
49+
```ts
50+
import { withTimeout } from "@borderless/context";
51+
52+
const [ctx, abort] = withTimeout(parentCtx, 5000); // You can still `abort` manually.
53+
```
54+
55+
### Using Abort
56+
57+
The `useAbort` method will return a `Promise` which rejects when aborted.
58+
59+
```ts
60+
import { useAbort } from "@borderless/context";
61+
62+
// Race between the abort signal and making an ajax request.
63+
Promise.race([useAbort(ctx), ajax("http://example.com")]);
3164
```
3265

3366
## Example
3467

35-
Tracing is a natural example for `context`:
68+
### Abort Controller
69+
70+
Use `context` with other abort signals, such as `fetch`.
71+
72+
```ts
73+
import { useAbort, Context } from "@borderless/context";
74+
75+
function request(ctx: Context<{}>, url: string) {
76+
const controller = new AbortController();
77+
withAbort(ctx).catch(e => controller.abort());
78+
return fetch(url, { signal: controller.signal });
79+
}
80+
```
81+
82+
### Application Tracing
83+
84+
Distributed application tracing is a natural example for `context`:
3685

3786
```ts
3887
import { Context, withValue } from "@borderless/context";
@@ -54,7 +103,7 @@ export function startSpan<T extends { [spanKey]?: Span }>(
54103

55104
// server.js
56105
export async function app(req, next) {
57-
const [span, ctx] = startSpan(req.ctx, "request");
106+
const [span, ctx] = startSpan(req.ctx, "app");
58107

59108
req.ctx = ctx;
60109

@@ -79,6 +128,25 @@ export async function middleware(req, next) {
79128
}
80129
```
81130

131+
### Libraries
132+
133+
JavaScript and TypeScript libraries can accept a typed `context` argument.
134+
135+
```ts
136+
import { Context, withValue } from "@borderless/context";
137+
138+
export function withSentry<T>(ctx: Context<T>) {
139+
return withValue(ctx, sentryKey, someSentryImplementation);
140+
}
141+
142+
export function captureException(
143+
ctx: Context<{ [sentryKey]: SomeSentryImplementation }>,
144+
error: Error
145+
) {
146+
return ctx.value(sentryKey).captureException(error);
147+
}
148+
```
149+
82150
## License
83151

84152
MIT

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"size-limit": [
5050
{
5151
"path": "dist/index.js",
52-
"limit": "200 B"
52+
"limit": "260 B"
5353
}
5454
],
5555
"jest": {

src/index.spec.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,80 @@
11
import * as context from "./index";
2+
import { performance } from "perf_hooks";
23

34
const testKey = Symbol("test");
45

56
describe("assign", () => {
67
const ctx = context.background;
78

8-
it("should hold values", () => {
9-
const newCtx = context.withValue(ctx, "test", true);
10-
const anotherCtx = context.withValue(newCtx, testKey, 123);
9+
describe("withValue", () => {
10+
it("should hold values", () => {
11+
const newCtx = context.withValue(ctx, "test", true);
12+
const anotherCtx = context.withValue(newCtx, testKey, 123);
1113

12-
expect(newCtx.value("test")).toEqual(true);
13-
expect(anotherCtx.value("test")).toEqual(true);
14-
expect(anotherCtx.value(testKey)).toEqual(123);
14+
expect(newCtx.value("test")).toEqual(true);
15+
expect(anotherCtx.value("test")).toEqual(true);
16+
expect(anotherCtx.value(testKey)).toEqual(123);
17+
});
18+
19+
it("should allow optional keys", () => {
20+
const test = (ctx: context.Context<{ prop?: boolean }>) => {
21+
return ctx.value("prop");
22+
};
23+
24+
expect(test(ctx)).toEqual(undefined);
25+
expect(test(context.withValue(ctx, "prop", true))).toEqual(true);
26+
});
1527
});
1628

17-
it("should allow optional keys", () => {
18-
const test = (ctx: context.Context<{ prop?: boolean }>) => {
19-
return ctx.value("prop");
20-
};
29+
describe("withAbort", () => {
30+
it("should allow abort", async () => {
31+
const fn = jest.fn();
32+
const reason = new Error();
33+
const [newCtx, abort] = context.withAbort(ctx);
34+
35+
context.useAbort(ctx).catch(fn);
36+
context.useAbort(newCtx).catch(fn);
37+
38+
expect(fn).toHaveBeenCalledTimes(0);
39+
40+
abort(reason);
41+
42+
await expect(context.useAbort(newCtx)).rejects.toBe(reason);
43+
44+
expect(fn).toHaveBeenCalledTimes(1);
45+
});
46+
47+
it("should inherit abort", async () => {
48+
const reason = new Error();
49+
const [newCtx, abort] = context.withAbort(ctx);
50+
const [anotherCtx] = context.withAbort(newCtx);
51+
52+
abort(reason);
53+
54+
await expect(context.useAbort(newCtx)).rejects.toBe(reason);
55+
await expect(context.useAbort(anotherCtx)).rejects.toBe(reason);
56+
});
57+
});
58+
59+
describe("withTimeout", () => {
60+
it("should abort on a timeout", async () => {
61+
const start = performance.now();
62+
const [newCtx] = context.withTimeout(ctx, 100);
63+
64+
await expect(context.useAbort(newCtx)).rejects.toBeInstanceOf(Error);
65+
66+
const end = performance.now();
67+
68+
expect(end - start).toBeGreaterThan(100);
69+
});
70+
71+
it("should allow manual abort", async () => {
72+
const reason = new Error();
73+
const [newCtx, abort] = context.withTimeout(ctx, 100);
74+
75+
abort(reason);
2176

22-
expect(test(ctx)).toEqual(undefined);
23-
expect(test(context.withValue(ctx, "prop", true))).toEqual(true);
77+
await expect(context.useAbort(newCtx)).rejects.toBe(reason);
78+
});
2479
});
2580
});

src/index.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,25 @@ export interface Context<T> {
55
/**
66
* The `BackgroundContext` object implements the default `Context` interface.
77
*/
8-
class BackgroundContext<T = {}> implements Context<T> {
9-
value<K extends keyof T>(key: K): T[K] {
10-
return undefined as any;
8+
class BackgroundContext implements Context<{}> {
9+
value(): never {
10+
return undefined as never;
1111
}
1212
}
1313

1414
/**
1515
* The `ValueContext` object implements a chain-able `Context` interface.
1616
*/
17-
class ValueContext<T> implements Context<T> {
17+
class ValueContext<P, T> implements Context<T> {
1818
constructor(
19-
private _parent: Context<any>,
20-
private _key: keyof T,
21-
private _value: T[typeof _key]
19+
private p: Context<P>,
20+
private k: keyof T,
21+
private v: T[typeof k]
2222
) {}
2323

24-
value<K extends keyof T>(key: K): T[K] {
25-
if (key === this._key) return this._value as any;
26-
return this._parent.value(key);
24+
value<K extends keyof (T & P)>(key: K): (T & P)[K] {
25+
if (key === this.k) return this.v as (T & P)[K];
26+
return this.p.value(key as keyof P) as (T & P)[K];
2727
}
2828
}
2929

@@ -40,5 +40,53 @@ export function withValue<T, K extends PropertyKey, V extends any>(
4040
key: K,
4141
value: V
4242
): Context<T & Record<K, V>> {
43-
return new ValueContext<T & Record<K, V>>(parent, key, value as any);
43+
return new ValueContext<T, Record<K, V>>(parent, key, value);
44+
}
45+
46+
/**
47+
* Abort function type.
48+
*/
49+
export type AbortFn = (reason: Error) => void;
50+
51+
/**
52+
* Abort symbol for context.
53+
*/
54+
const abortKey = Symbol("abort");
55+
56+
/**
57+
* Values used to manage `abort` in the context.
58+
*/
59+
export type AbortContextValue = Record<typeof abortKey, Promise<never>>;
60+
61+
/**
62+
* Create a cancellable `context` object.
63+
*/
64+
export function withAbort<T>(
65+
parent: Context<T & Partial<AbortContextValue>>
66+
): [Context<T & AbortContextValue>, AbortFn] {
67+
let abort: AbortFn;
68+
let prev: Promise<never> | undefined;
69+
const promise = new Promise<never>((_, reject) => (abort = reject));
70+
(prev = parent.value(abortKey)) && prev.catch(abort!); // Propagate aborts.
71+
return [withValue(parent, abortKey, promise), abort!];
72+
}
73+
74+
/**
75+
* Create a `context` which aborts after _ms_.
76+
*/
77+
export function withTimeout<T>(
78+
parent: Context<T>,
79+
ms: number
80+
): [Context<T & AbortContextValue>, AbortFn] {
81+
const [ctx, cancel] = withAbort(parent);
82+
const timeout = setTimeout(cancel, ms, new Error("Context timed out"));
83+
const abort = (reason: Error) => (clearTimeout(timeout), cancel(reason));
84+
return [ctx, abort];
85+
}
86+
87+
/**
88+
* Use the abort signal.
89+
*/
90+
export function useAbort(ctx: Context<Partial<AbortContextValue>>) {
91+
return ctx.value(abortKey) || new Promise<never>(() => 0);
4492
}

0 commit comments

Comments
 (0)