Skip to content

Commit

Permalink
Add backward-compatible support for abort
Browse files Browse the repository at this point in the history
  • Loading branch information
blakeembrey committed Mar 13, 2020
1 parent 31bdc44 commit f3d3a43
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 19 deletions.
82 changes: 75 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

> Tiny, type-safe, JavaScript-native `context` implementation.
**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.
**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.

## Installation

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

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

anotherContext.value("test"); //=> "test"
defaultContext.value("test"); // Invalid.
ctx.value("test"); //=> "test"
background.value("test"); // Invalid.
```

### Abort

Use `withAbort` to support cancellation of execution in your application.

```ts
import { withAbort } from "@borderless/context";

const [ctx, abort] = withAbort(parentCtx);

onUserCancelsTask(() => abort(new Error("User canceled task")));
```

### Timeout

Use `withTimeout` when you want to abort after a specific duration:

```ts
import { withTimeout } from "@borderless/context";

const [ctx, abort] = withTimeout(parentCtx, 5000); // You can still `abort` manually.
```

### Using Abort

The `useAbort` method will return a `Promise` which rejects when aborted.

```ts
import { useAbort } from "@borderless/context";

// Race between the abort signal and making an ajax request.
Promise.race([useAbort(ctx), ajax("http://example.com")]);
```

## Example

Tracing is a natural example for `context`:
### Abort Controller

Use `context` with other abort signals, such as `fetch`.

```ts
import { useAbort, Context } from "@borderless/context";

function request(ctx: Context<{}>, url: string) {
const controller = new AbortController();
withAbort(ctx).catch(e => controller.abort());
return fetch(url, { signal: controller.signal });
}
```

### Application Tracing

Distributed application tracing is a natural example for `context`:

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

// server.js
export async function app(req, next) {
const [span, ctx] = startSpan(req.ctx, "request");
const [span, ctx] = startSpan(req.ctx, "app");

req.ctx = ctx;

Expand All @@ -79,6 +128,25 @@ export async function middleware(req, next) {
}
```

### Libraries

JavaScript and TypeScript libraries can accept a typed `context` argument.

```ts
import { Context, withValue } from "@borderless/context";

export function withSentry<T>(ctx: Context<T>) {
return withValue(ctx, sentryKey, someSentryImplementation);
}

export function captureException(
ctx: Context<{ [sentryKey]: SomeSentryImplementation }>,
error: Error
) {
return ctx.value(sentryKey).captureException(error);
}
```

## License

MIT
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"size-limit": [
{
"path": "dist/index.js",
"limit": "200 B"
"limit": "260 B"
}
],
"jest": {
Expand Down
70 changes: 59 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,25 @@ export interface Context<T> {
/**
* The `BackgroundContext` object implements the default `Context` interface.
*/
class BackgroundContext<T = {}> implements Context<T> {
value<K extends keyof T>(key: K): T[K] {
return undefined as any;
class BackgroundContext implements Context<{}> {
value(): never {
return undefined as never;
}
}

/**
* The `ValueContext` object implements a chain-able `Context` interface.
*/
class ValueContext<T> implements Context<T> {
class ValueContext<P, T> implements Context<T> {
constructor(
private _parent: Context<any>,
private _key: keyof T,
private _value: T[typeof _key]
private p: Context<P>,
private k: keyof T,
private v: T[typeof k]
) {}

value<K extends keyof T>(key: K): T[K] {
if (key === this._key) return this._value as any;
return this._parent.value(key);
value<K extends keyof (T & P)>(key: K): (T & P)[K] {
if (key === this.k) return this.v as (T & P)[K];
return this.p.value(key as keyof P) as (T & P)[K];
}
}

Expand All @@ -40,5 +40,53 @@ export function withValue<T, K extends PropertyKey, V extends any>(
key: K,
value: V
): Context<T & Record<K, V>> {
return new ValueContext<T & Record<K, V>>(parent, key, value as any);
return new ValueContext<T, Record<K, V>>(parent, key, value);
}

/**
* Abort function type.
*/
export type AbortFn = (reason: Error) => void;

/**
* Abort symbol for context.
*/
const abortKey = Symbol("abort");

/**
* Values used to manage `abort` in the context.
*/
export type AbortContextValue = Record<typeof abortKey, Promise<never>>;

/**
* Create a cancellable `context` object.
*/
export function withAbort<T>(
parent: Context<T & Partial<AbortContextValue>>
): [Context<T & AbortContextValue>, AbortFn] {
let abort: AbortFn;
let prev: Promise<never> | undefined;
const promise = new Promise<never>((_, reject) => (abort = reject));
(prev = parent.value(abortKey)) && prev.catch(abort!); // Propagate aborts.
return [withValue(parent, abortKey, promise), abort!];
}

/**
* Create a `context` which aborts after _ms_.
*/
export function withTimeout<T>(
parent: Context<T>,
ms: number
): [Context<T & AbortContextValue>, AbortFn] {
const [ctx, cancel] = withAbort(parent);
const timeout = setTimeout(cancel, ms, new Error("Context timed out"));
const abort = (reason: Error) => (clearTimeout(timeout), cancel(reason));
return [ctx, abort];
}

/**
* Use the abort signal.
*/
export function useAbort(ctx: Context<Partial<AbortContextValue>>) {
return ctx.value(abortKey) || new Promise<never>(() => 0);
}

0 comments on commit f3d3a43

Please sign in to comment.