@wry/context
Version:
Manage contextual information needed by (a)synchronous tasks without explicitly passing objects around
184 lines (169 loc) • 6.89 kB
text/typescript
type Context = {
parent: Context | null;
slots: { [slotId: string]: any };
}
// This currentContext variable will only be used if the makeSlotClass
// function is called, which happens only if this is the first copy of the
// @wry/context package to be imported.
let currentContext: Context | null = null;
// This unique internal object is used to denote the absence of a value
// for a given Slot, and is never exposed to outside code.
const MISSING_VALUE: any = {};
let idCounter = 1;
// Although we can't do anything about the cost of duplicated code from
// accidentally bundling multiple copies of the @wry/context package, we can
// avoid creating the Slot class more than once using makeSlotClass.
const makeSlotClass = () => class Slot<TValue> {
// If you have a Slot object, you can find out its slot.id, but you cannot
// guess the slot.id of a Slot you don't have access to, thanks to the
// randomized suffix.
public readonly id = [
"slot",
idCounter++,
Date.now(),
Math.random().toString(36).slice(2),
].join(":");
public hasValue() {
for (let context = currentContext; context; context = context.parent) {
// We use the Slot object iself as a key to its value, which means the
// value cannot be obtained without a reference to the Slot object.
if (this.id in context.slots) {
const value = context.slots[this.id];
if (value === MISSING_VALUE) break;
if (context !== currentContext) {
// Cache the value in currentContext.slots so the next lookup will
// be faster. This caching is safe because the tree of contexts and
// the values of the slots are logically immutable.
currentContext!.slots[this.id] = value;
}
return true;
}
}
if (currentContext) {
// If a value was not found for this Slot, it's never going to be found
// no matter how many times we look it up, so we might as well cache
// the absence of the value, too.
currentContext.slots[this.id] = MISSING_VALUE;
}
return false;
}
public getValue(): TValue | undefined {
if (this.hasValue()) {
return currentContext!.slots[this.id] as TValue;
}
}
public withValue<TResult, TArgs extends any[], TThis = any>(
value: TValue,
callback: (this: TThis, ...args: TArgs) => TResult,
// Given the prevalence of arrow functions, specifying arguments is likely
// to be much more common than specifying `this`, hence this ordering:
args?: TArgs,
thisArg?: TThis,
): TResult {
const slots = {
__proto__: null,
[this.id]: value,
};
const parent = currentContext;
currentContext = { parent, slots };
try {
// Function.prototype.apply allows the arguments array argument to be
// omitted or undefined, so args! is fine here.
return callback.apply(thisArg!, args!);
} finally {
currentContext = parent;
}
}
// Capture the current context and wrap a callback function so that it
// reestablishes the captured context when called.
static bind<TArgs extends any[], TResult, TThis = any>(
callback: (this: TThis, ...args: TArgs) => TResult,
) {
const context = currentContext;
return function (this: TThis) {
const saved = currentContext;
try {
currentContext = context;
return callback.apply(this, arguments as any);
} finally {
currentContext = saved;
}
} as typeof callback;
}
// Immediately run a callback function without any captured context.
static noContext<TResult, TArgs extends any[], TThis = any>(
callback: (this: TThis, ...args: TArgs) => TResult,
// Given the prevalence of arrow functions, specifying arguments is likely
// to be much more common than specifying `this`, hence this ordering:
args?: TArgs,
thisArg?: TThis,
) {
if (currentContext) {
const saved = currentContext;
try {
currentContext = null;
// Function.prototype.apply allows the arguments array argument to be
// omitted or undefined, so args! is fine here.
return callback.apply(thisArg!, args!);
} finally {
currentContext = saved;
}
} else {
return callback.apply(thisArg!, args!);
}
}
};
function maybe<T>(fn: () => T): T | undefined {
try {
return fn();
} catch (ignored) {}
}
// We store a single global implementation of the Slot class as a permanent
// non-enumerable property of the globalThis object. This obfuscation does
// nothing to prevent access to the Slot class, but at least it ensures the
// implementation (i.e. currentContext) cannot be tampered with, and all copies
// of the @wry/context package (hopefully just one) will share the same Slot
// implementation. Since the first copy of the @wry/context package to be
// imported wins, this technique imposes a steep cost for any future breaking
// changes to the Slot class.
const globalKey = "@wry/context:Slot";
const host =
// Prefer globalThis when available.
// https://github.com/benjamn/wryware/issues/347
maybe(() => globalThis) ||
// Fall back to global, which works in Node.js and may be converted by some
// bundlers to the appropriate identifier (window, self, ...) depending on the
// bundling target. https://github.com/endojs/endo/issues/576#issuecomment-1178515224
maybe(() => global) ||
// Otherwise, use a dummy host that's local to this module. We used to fall
// back to using the Array constructor as a namespace, but that was flagged in
// https://github.com/benjamn/wryware/issues/347, and can be avoided.
Object.create(null) as typeof Array;
// Whichever globalHost we're using, make TypeScript happy about the additional
// globalKey property.
const globalHost: typeof host & {
[globalKey]?: typeof Slot;
} = host;
export const Slot: ReturnType<typeof makeSlotClass> =
globalHost[globalKey] ||
// Earlier versions of this package stored the globalKey property on the Array
// constructor, so we check there as well, to prevent Slot class duplication.
(Array as typeof globalHost)[globalKey] ||
(function (Slot) {
try {
Object.defineProperty(globalHost, globalKey, {
value: Slot,
enumerable: false,
writable: false,
// When it was possible for globalHost to be the Array constructor (a
// legacy Slot dedup strategy), it was important for the property to be
// configurable:true so it could be deleted. That does not seem to be as
// important when globalHost is the global object, but I don't want to
// cause similar problems again, and configurable:true seems safest.
// https://github.com/endojs/endo/issues/576#issuecomment-1178274008
configurable: true
});
} finally {
return Slot;
}
})(makeSlotClass());