diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 4372d96b6..4bb5bd636 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -74,7 +74,7 @@ jobs:
path: ./build/npm
- name: Publish NPM
- run: npm publish --access=public --tag=latest
+ run: npm publish --access=public --tag=next
working-directory: ./build/npm
publish-jsr:
diff --git a/deno.json b/deno.json
index 1c913fe6b..1f2cd1cde 100644
--- a/deno.json
+++ b/deno.json
@@ -2,7 +2,7 @@
"name": "@effection/effection",
"exports": "./mod.ts",
"license": "ISC",
- "publish": { "include": ["lib", "mod.ts", "README.md"] },
+ "publish": { "include": ["lib", "mod.ts", "experimental.ts", "README.md"] },
"lock": false,
"tasks": {
"test": "deno test --allow-run --allow-read --allow-env --allow-ffi",
@@ -12,7 +12,7 @@
"build:jsr": "deno run -A tasks/build-jsr.ts"
},
"lint": {
- "rules": { "exclude": ["prefer-const", "require-yield"] },
+ "rules": { "exclude": ["prefer-const", "require-yield", "ban-types"] },
"exclude": ["build", "docs", "tasks"],
"plugins": ["lint/prefer-let.ts"]
},
@@ -29,6 +29,10 @@
"tinyexec": "npm:tinyexec@1.0.1",
"https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "npm:path-to-regexp@8.2.0"
},
+ "exports": {
+ ".": "./mod.ts",
+ "./experimental": "./experimental.ts"
+ },
"nodeModulesDir": "auto",
"workspace": [
"./www"
diff --git a/experimental.ts b/experimental.ts
new file mode 100644
index 000000000..9cb827705
--- /dev/null
+++ b/experimental.ts
@@ -0,0 +1,6 @@
+export * from "./lib/api.ts";
+export {
+ InstructionQueue,
+ type Instruction,
+} from "./lib/reducer.ts";
+export { DelimiterContext } from "./lib/delimiter.ts";
diff --git a/lib/api-internal.ts b/lib/api-internal.ts
new file mode 100644
index 000000000..7b66c2ee5
--- /dev/null
+++ b/lib/api-internal.ts
@@ -0,0 +1,142 @@
+// deno-lint-ignore-file ban-types no-explicit-any
+import { createContext } from "./context.ts";
+import { useScope } from "./scope.ts";
+import type {
+ Api,
+ Around,
+ Context,
+ Middleware,
+ Operation,
+ Scope,
+} from "./types.ts";
+import type { ScopeInternal } from "./scope-internal.ts";
+
+export interface ApiInternal extends Api {
+ context: Context<{
+ max: Partial>[];
+ min: Partial>[];
+ }>;
+ core: A;
+}
+
+export function createApiInternal(
+ name: string,
+ core: A,
+): ApiInternal {
+ let fields = Object.keys(core) as (keyof A)[];
+
+ let context = createContext(`api::${name}`) as ApiInternal["context"];
+
+ let api: ApiInternal = {
+ core,
+ context,
+ invoke: (scope, key, args) => {
+ let handle = createHandle(api, scope);
+ let member = handle[key];
+ if (typeof member === "function") {
+ return member(...args);
+ } else {
+ return member;
+ }
+ },
+ around: (decorator, options = { at: "max" }) => ({
+ *[Symbol.iterator]() {
+ let scope = yield* useScope();
+ scope.around(api, decorator, options);
+ },
+ }),
+ operations: fields.reduce((sum, field) => {
+ if (typeof core[field] === "function") {
+ return Object.assign(sum, {
+ [field]: (...args: any) => ({
+ *[Symbol.iterator]() {
+ let scope = yield* useScope();
+ let target = api.invoke(scope, field, args);
+
+ return isOperation(target) ? yield* target : target;
+ },
+ }),
+ });
+ } else {
+ return Object.assign(sum, {
+ [field]: {
+ *[Symbol.iterator]() {
+ let scope = yield* useScope();
+ let target = api.invoke(scope, field, [] as any);
+ return isOperation(target) ? yield* target : target;
+ },
+ },
+ });
+ }
+ }, {} as Api["operations"]),
+ };
+ return api;
+}
+
+function createHandle(api: ApiInternal, scope: Scope): A {
+ let handle = Object.create(api.core);
+ let $scope = scope as ScopeInternal;
+ for (let key of Object.keys(api.core) as Array) {
+ let dispatch = (args: unknown[], next: (...args: unknown[]) => unknown) => {
+ let { min, max } = $scope.reduce(api.context, (sum, current) => {
+ let min = current.min.flatMap((around) =>
+ around[key] ? [around[key]] : []
+ );
+ let max = current.max.flatMap((around) =>
+ around[key] ? [around[key]] : []
+ );
+
+ sum.min.push(...min);
+ sum.max.unshift(...max);
+
+ return sum;
+ }, {
+ min: [] as Around[typeof key][],
+ max: [] as Around[typeof key][],
+ });
+
+ let stack = combine(
+ max.concat(min) as Middleware[],
+ );
+ return stack(args, next);
+ };
+
+ if (typeof api.core[key] === "function") {
+ handle[key] = (...args: unknown[]) =>
+ dispatch(args, api.core[key] as (...args: unknown[]) => unknown);
+ } else {
+ Object.defineProperty(handle, key, {
+ enumerable: true,
+ get() {
+ return dispatch([], () => api.core[key]);
+ },
+ });
+ }
+ }
+ return handle;
+}
+
+function isOperation(
+ target: Operation | T,
+): target is Operation {
+ return target && !isNativeIterable(target) &&
+ typeof (target as Operation)[Symbol.iterator] === "function";
+}
+
+function isNativeIterable(target: unknown): boolean {
+ return (
+ typeof target === "string" || Array.isArray(target) ||
+ target instanceof Map || target instanceof Set
+ );
+}
+
+function combine(
+ middlewares: Middleware[],
+): Middleware {
+ if (middlewares.length === 0) {
+ return (args, next) => next(...args);
+ }
+ return middlewares.reduceRight((sum, middleware) => (args, next) =>
+ middleware(args, (...args) => sum(args, next))
+ );
+}
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 000000000..4523a4f50
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,55 @@
+// deno-lint-ignore-file ban-types
+import type { Api, Context, Operation, Scope } from "./types.ts";
+import { createApiInternal } from "./api-internal.ts";
+import type { ScopeInternal } from "./scope-internal.ts";
+import type { Instruction } from "./reducer.ts";
+
+export function createApi(name: string, core: A): Api {
+ return createApiInternal(name, core);
+}
+
+interface ScopeApi {
+ create(parent: Scope): [Scope, () => Operation];
+ destroy(scope: Scope): Operation;
+ set(scope: Scope, context: Context, value: T): T;
+ delete(scope: Scope, context: Context): boolean;
+}
+
+interface MainApi {
+ main(body: (args: string[]) => Operation): Promise;
+}
+
+interface ReducerApi {
+ reduce(instruction: Instruction): void;
+}
+
+interface Apis {
+ Scope: Api;
+ Main: Api;
+ Reducer: Api;
+}
+
+export const api: Apis = {
+ Scope: createApi("Scope", {
+ create() {
+ throw new TypeError(`no handler for Scope.create()`);
+ },
+ *destroy() {},
+ set(scope, context, value) {
+ return (scope as ScopeInternal).contexts[context.name] = value;
+ },
+ delete(scope, context) {
+ return delete (scope as ScopeInternal).contexts[context.name];
+ },
+ }),
+ Main: createApi("Main", {
+ main() {
+ throw new TypeError(`missing handler for "main()"`);
+ },
+ }),
+ Reducer: createApi("Reducer", {
+ reduce() {
+ throw new TypeError(`no handler for Reducer.reduce()`);
+ },
+ }),
+};
diff --git a/lib/attributes-internal.ts b/lib/attributes-internal.ts
new file mode 100644
index 000000000..495b5a40a
--- /dev/null
+++ b/lib/attributes-internal.ts
@@ -0,0 +1,67 @@
+import { createContext } from "./context.ts";
+import { useScope } from "./scope.ts";
+import type { Operation, Scope } from "./types.ts";
+
+/**
+ * Serializable name/value pairs that can be used for visualizing and
+ * inpsecting Effection scopes. There will always be at least a name
+ * in the attributes.
+ */
+export type Attributes =
+ & { name: string }
+ & Record;
+
+const AttributesContext = createContext(
+ "@effection/attributes",
+ { name: "anonymous" },
+);
+
+/**
+ * Add metadata to the current {@link Scope} that can be used for
+ * display and debugging purposes.
+ *
+ * Calling `useAttributes()` multiple times will add new attributes
+ * and overwrite attributes of the same name, but it will not erase
+ * old ones.
+ *
+ * @example
+ * ```ts
+ * function useServer(port: number): Operation {
+ * return resource(function*(provide) {
+ * yield* useAttributes({ name: "Server", port });
+ * let server = createServer();
+ * server.listen();
+ * try {
+ * yield* provide(server);
+ * } finally {
+ * server.close();
+ * }
+ * });
+ * }
+ * ```
+ *
+ * @param attrs - attributes to add to this {@link Scope}
+ * @returns an Oeration adding `attrs` to the current scope
+ * @since 4.1
+ */
+export function* useAttributes(attrs: Partial): Operation {
+ let scope = yield* useScope();
+
+ let current = scope.hasOwn(AttributesContext)
+ ? scope.expect(AttributesContext)
+ : AttributesContext.defaultValue!;
+
+ scope.set(AttributesContext, { ...current, ...attrs });
+}
+
+/**
+ * Get the unique attributes of this {@link Scope}. Attributes are not
+ * inherited and only the attributes explicitly assigned to this scope
+ * will be returned.
+ */
+export function getAttributes(scope: Scope) {
+ if (scope.hasOwn(AttributesContext)) {
+ return scope.expect(AttributesContext);
+ }
+ return AttributesContext.defaultValue as Attributes;
+}
diff --git a/lib/attributes.ts b/lib/attributes.ts
new file mode 100644
index 000000000..f40df47be
--- /dev/null
+++ b/lib/attributes.ts
@@ -0,0 +1 @@
+export { type Attributes, useAttributes } from "./attributes-internal.ts";
diff --git a/lib/callcc.ts b/lib/callcc.ts
index 5d969c95a..e5d8ff9f6 100644
--- a/lib/callcc.ts
+++ b/lib/callcc.ts
@@ -11,7 +11,7 @@ export function* callcc(
reject: (error: Error) => Operation,
) => Operation,
): Operation {
- let result = withResolvers>();
+ let result = withResolvers>("await callcc");
let resolve = lift((value: T) => result.resolve(Ok(value)));
diff --git a/lib/coroutine.ts b/lib/coroutine.ts
index f0c6a1bfa..23a8758eb 100644
--- a/lib/coroutine.ts
+++ b/lib/coroutine.ts
@@ -1,9 +1,11 @@
+import { api as effection } from "./api.ts";
import { Priority } from "./contexts.ts";
import { DelimiterContext } from "./delimiter.ts";
-import { ReducerContext } from "./reducer.ts";
import { Ok } from "./result.ts";
import type { Coroutine, Operation, Scope } from "./types.ts";
+const reducerApi = effection.Reducer;
+
export interface CoroutineOptions {
scope: Scope;
operation(): Operation;
@@ -12,8 +14,6 @@ export interface CoroutineOptions {
export function createCoroutine(
{ operation, scope }: CoroutineOptions,
): Coroutine {
- let reducer = scope.expect(ReducerContext);
-
let iterator: Coroutine["data"]["iterator"] | undefined = undefined;
let routine = {
@@ -31,25 +31,25 @@ export function createCoroutine(
next(result) {
routine.data.exit((exitResult) => {
routine.data.exit = (didExit) => didExit(Ok());
- reducer.reduce([
- scope.expect(Priority),
+ reducerApi.invoke(routine.scope, "reduce", [[
+ routine.scope.expect(Priority),
routine,
exitResult.ok ? result : exitResult,
- scope.expect(DelimiterContext).validator,
+ routine.scope.expect(DelimiterContext).validator,
"next",
- ]);
+ ]]);
});
},
return(result) {
routine.data.exit((exitResult) => {
routine.data.exit = (didExit) => didExit(Ok());
- reducer.reduce([
- scope.expect(Priority),
+ reducerApi.invoke(routine.scope, "reduce", [[
+ routine.scope.expect(Priority),
routine,
exitResult.ok ? result : exitResult,
- scope.expect(DelimiterContext).validator,
+ routine.scope.expect(DelimiterContext).validator,
"return",
- ]);
+ ]]);
});
},
} as Coroutine;
diff --git a/lib/delimiter.ts b/lib/delimiter.ts
index a262961e8..05b67ff92 100644
--- a/lib/delimiter.ts
+++ b/lib/delimiter.ts
@@ -10,7 +10,7 @@ export class Delimiter
implements Operation>>, ErrorBoundary {
level = 0;
finalized = false;
- future = withResolvers>>();
+ future = withResolvers>>("await delimiter");
computed = false;
routine?: Coroutine;
outcome?: Maybe>;
diff --git a/lib/each.ts b/lib/each.ts
index 320c0088a..d1865c006 100644
--- a/lib/each.ts
+++ b/lib/each.ts
@@ -37,8 +37,8 @@ export function each(stream: Stream): Operation> {
scope.set(EachStack, []);
}
- let done = withResolvers();
- let cxt = withResolvers>();
+ let done = withResolvers("await each done");
+ let cxt = withResolvers>("await each context");
yield* spawn(function* () {
let subscription = yield* stream;
diff --git a/lib/future.ts b/lib/future.ts
index b61517651..fff2a300a 100644
--- a/lib/future.ts
+++ b/lib/future.ts
@@ -9,7 +9,7 @@ export interface FutureWithResolvers {
}
export function createFuture(): FutureWithResolvers {
let promise = lazyPromiseWithResolvers();
- let operation = withResolvers();
+ let operation = withResolvers("await future");
let resolve = (value: T) => {
promise.resolve(value);
diff --git a/lib/main.ts b/lib/main.ts
index 3ec5dfe28..cc648e847 100644
--- a/lib/main.ts
+++ b/lib/main.ts
@@ -2,8 +2,9 @@ import { createContext } from "./context.ts";
import type { Operation } from "./types.ts";
import { callcc } from "./callcc.ts";
import { run } from "./run.ts";
-import { useScope } from "./scope.ts";
+import { global, useScope } from "./scope.ts";
import { call } from "./call.ts";
+import { api } from "./api.ts";
/**
* Halt process execution immediately and initiate shutdown. If a message is
@@ -60,103 +61,109 @@ export function* exit(status: number, message?: string): Operation {
* @since 3.0
*/
-export async function main(
+export function main(
body: (args: string[]) => Operation,
): Promise {
- let hardexit = (_status: number) => {};
-
- let result = await run(() =>
- callcc(function* (resolve) {
- // action will return shutdown immediately upon resolve, so stash
- // this function in the exit context so it can be called anywhere.
- yield* ExitContext.set(resolve);
-
- // this will hold the event loop and prevent runtimes such as
- // Node and Deno from exiting prematurely.
- let interval = setInterval(() => {}, Math.pow(2, 30));
-
- let scope = yield* useScope();
-
- try {
- let interrupt = {
- SIGINT: () =>
- scope.run(() => resolve({ status: 130, signal: "SIGINT" })),
- SIGTERM: () =>
- scope.run(() => resolve({ status: 143, signal: "SIGTERM" })),
- };
-
- yield* withHost({
- *deno() {
- hardexit = (status) => Deno.exit(status);
- try {
- Deno.addSignalListener("SIGINT", interrupt.SIGINT);
- /**
- * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP)
- */
- if (Deno.build.os !== "windows") {
- Deno.addSignalListener("SIGTERM", interrupt.SIGTERM);
- }
- yield* body(Deno.args.slice());
- } finally {
- Deno.removeSignalListener("SIGINT", interrupt.SIGINT);
- if (Deno.build.os !== "windows") {
- Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM);
+ return api.Main.invoke(global, "main", [body]);
+}
+
+global.around(api.Main, {
+ async main([body]) {
+ let hardexit = (_status: number) => {};
+
+ let result = await run(() =>
+ callcc(function* (resolve) {
+ // action will return shutdown immediately upon resolve, so stash
+ // this function in the exit context so it can be called anywhere.
+ yield* ExitContext.set(resolve);
+
+ // this will hold the event loop and prevent runtimes such as
+ // Node and Deno from exiting prematurely.
+ let interval = setInterval(() => {}, Math.pow(2, 30));
+
+ let scope = yield* useScope();
+
+ try {
+ let interrupt = {
+ SIGINT: () =>
+ scope.run(() => resolve({ status: 130, signal: "SIGINT" })),
+ SIGTERM: () =>
+ scope.run(() => resolve({ status: 143, signal: "SIGTERM" })),
+ };
+
+ yield* withHost({
+ *deno() {
+ hardexit = (status) => Deno.exit(status);
+ try {
+ Deno.addSignalListener("SIGINT", interrupt.SIGINT);
+ /**
+ * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP)
+ */
+ if (Deno.build.os !== "windows") {
+ Deno.addSignalListener("SIGTERM", interrupt.SIGTERM);
+ }
+ yield* body(Deno.args.slice());
+ } finally {
+ Deno.removeSignalListener("SIGINT", interrupt.SIGINT);
+ if (Deno.build.os !== "windows") {
+ Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM);
+ }
}
- }
- },
- *node() {
- // Annotate dynamic import so that webpack ignores it.
- // See https://webpack.js.org/api/module-methods/#webpackignore
- let { default: process } = yield* call(() =>
- import(/* webpackIgnore: true */ "node:process")
- );
- hardexit = (status) => process.exit(status);
- try {
- process.on("SIGINT", interrupt.SIGINT);
- if (process.platform !== "win32") {
- process.on("SIGTERM", interrupt.SIGTERM);
+ },
+ *node() {
+ // Annotate dynamic import so that webpack ignores it.
+ // See https://webpack.js.org/api/module-methods/#webpackignore
+ let { default: process } = yield* call(() =>
+ import(/* webpackIgnore: true */ "node:process")
+ );
+ hardexit = (status) => process.exit(status);
+ try {
+ process.on("SIGINT", interrupt.SIGINT);
+ if (process.platform !== "win32") {
+ process.on("SIGTERM", interrupt.SIGTERM);
+ }
+ yield* body(process.argv.slice(2));
+ } finally {
+ process.off("SIGINT", interrupt.SIGINT);
+ if (process.platform !== "win32") {
+ process.off("SIGTERM", interrupt.SIGINT);
+ }
}
- yield* body(process.argv.slice(2));
- } finally {
- process.off("SIGINT", interrupt.SIGINT);
- if (process.platform !== "win32") {
- process.off("SIGTERM", interrupt.SIGINT);
+ },
+ *browser() {
+ try {
+ self.addEventListener("unload", interrupt.SIGINT);
+ yield* body([]);
+ } finally {
+ self.removeEventListener("unload", interrupt.SIGINT);
}
- }
- },
- *browser() {
- try {
- self.addEventListener("unload", interrupt.SIGINT);
- yield* body([]);
- } finally {
- self.removeEventListener("unload", interrupt.SIGINT);
- }
- },
- });
-
- yield* exit(0);
- } catch (error) {
- yield* resolve({ status: 1, error: error as Error });
- } finally {
- clearInterval(interval);
+ },
+ });
+
+ yield* exit(0);
+ } catch (error) {
+ yield* resolve({ status: 1, error: error as Error });
+ } finally {
+ clearInterval(interval);
+ }
+ })
+ );
+
+ if (result.message) {
+ if (result.status === 0) {
+ console.log(result.message);
+ } else {
+ console.error(result.message);
}
- })
- );
-
- if (result.message) {
- if (result.status === 0) {
- console.log(result.message);
- } else {
- console.error(result.message);
}
- }
- if (result.error) {
- console.error(result.error);
- }
+ if (result.error) {
+ console.error(result.error);
+ }
- hardexit(result.status);
-}
+ hardexit(result.status);
+ },
+}, { at: "min" });
const ExitContext = createContext<(exit: Exit) => Operation>("exit");
diff --git a/lib/mod.ts b/lib/mod.ts
index e15e4d3c5..c0d1a2d1f 100644
--- a/lib/mod.ts
+++ b/lib/mod.ts
@@ -2,6 +2,7 @@ export * from "./types.ts";
export * from "./result.ts";
export * from "./action.ts";
export * from "./context.ts";
+export * from "./attributes.ts";
export * from "./scope.ts";
export * from "./suspend.ts";
export * from "./sleep.ts";
diff --git a/lib/reducer.ts b/lib/reducer.ts
index 45822a468..8e7d74dd1 100644
--- a/lib/reducer.ts
+++ b/lib/reducer.ts
@@ -58,7 +58,7 @@ export class Reducer {
};
}
-type Instruction = [
+export type Instruction = [
number,
Coroutine,
Result,
@@ -66,7 +66,7 @@ type Instruction = [
"return" | "next",
];
-class InstructionQueue extends PriorityQueue {
+export class InstructionQueue extends PriorityQueue {
enqueue(instruction: Instruction): void {
let [priority] = instruction;
this.push(priority, instruction);
diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts
index 0e505f4f6..cc4ce0293 100644
--- a/lib/scope-internal.ts
+++ b/lib/scope-internal.ts
@@ -1,11 +1,42 @@
+import type { ApiInternal } from "./api-internal.ts";
+import { api as effection } from "./api.ts";
import { Children, Priority } from "./contexts.ts";
+import { Reducer } from "./reducer.ts";
import { Err, Ok, unbox } from "./result.ts";
import { createTask } from "./task.ts";
import type { Context, Operation, Scope, Task } from "./types.ts";
import { type WithResolvers, withResolvers } from "./with-resolvers.ts";
+const api = effection.Scope;
+const reducerApi = effection.Reducer;
+
export function createScopeInternal(
parent?: Scope,
+): [ScopeInternal, () => Operation] {
+ if (!parent) {
+ let [global, destroy] = buildScopeInternal();
+ global.around(api, {
+ create([parent]) {
+ return buildScopeInternal(parent);
+ },
+ }, { at: "min" });
+ let defaultReducer = new Reducer();
+ global.around(reducerApi, {
+ reduce([instruction]) {
+ defaultReducer.reduce(instruction);
+ },
+ }, { at: "min" });
+ return [global, destroy] as const;
+ } else {
+ return api.invoke(parent, "create", [parent]) as [
+ ScopeInternal,
+ () => Operation,
+ ];
+ }
+}
+
+export function buildScopeInternal(
+ parent?: Scope,
): [ScopeInternal, () => Operation] {
let destructors = new Set<() => Operation>();
@@ -19,7 +50,7 @@ export function createScopeInternal(
return (contexts[context.name] ?? context.defaultValue) as T | undefined;
},
set(context: Context, value: T): T {
- return contexts[context.name] = value;
+ return api.invoke(scope, "set", [scope, context, value]) as T;
},
expect(context: Context): T {
let value = scope.get(context);
@@ -31,7 +62,7 @@ export function createScopeInternal(
return value;
},
delete(context: Context): boolean {
- return delete contexts[context.name];
+ return api.invoke(scope, "delete", [scope, context]);
},
hasOwn(context: Context): boolean {
return !!Reflect.getOwnPropertyDescriptor(contexts, context.name);
@@ -51,47 +82,89 @@ export function createScopeInternal(
};
},
+ around(
+ api: ApiInternal,
+ ...params: Parameters["around"]>
+ ) {
+ let [around, options] = params;
+ if (!scope.hasOwn(api.context)) {
+ scope.set(api.context, { min: [], max: [] });
+ }
+
+ let { min, max } = scope.expect(api.context);
+
+ if (options?.at === "min") {
+ min.push(around);
+ } else {
+ max.push(around);
+ }
+ },
+
ensure(op: () => Operation): () => void {
destructors.add(op);
return () => destructors.delete(op);
},
+
+ reduce(
+ context: Context,
+ fn: (sum: S, item: T) => S,
+ initial: S,
+ ): S {
+ let sum = initial;
+ let current = contexts;
+ while (current) {
+ if (Object.hasOwn(current, context.name)) {
+ let item = current[context.name] as T;
+ if (item) {
+ sum = fn(sum, item);
+ }
+ }
+
+ current = Object.getPrototypeOf(current);
+ }
+ return sum;
+ },
});
scope.set(Priority, scope.expect(Priority) + 1);
scope.set(Children, new Set());
parent?.expect(Children).add(scope);
+ let destroy = () => api.invoke(scope, "destroy", [scope]);
+
let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {};
let destruction: WithResolvers | undefined = undefined;
- function* destroy(): Operation {
- if (destruction) {
- return yield* destruction.operation;
- }
- destruction = withResolvers();
- parent?.expect(Children).delete(scope);
- unbind();
- let outcome = Ok();
- try {
- for (let destructor of destructors) {
- try {
- destructors.delete(destructor);
- yield* destructor();
- } catch (error) {
- outcome = Err(error as Error);
- }
+ scope.around(api, {
+ *destroy(): Operation {
+ if (destruction) {
+ return yield* destruction.operation;
}
- } finally {
- if (outcome.ok) {
- destruction.resolve();
- } else {
- destruction.reject(outcome.error);
+ destruction = withResolvers("await destruction");
+ parent?.expect(Children).delete(scope);
+ unbind();
+ let outcome = Ok();
+ try {
+ for (let destructor of destructors) {
+ try {
+ destructors.delete(destructor);
+ yield* destructor();
+ } catch (error) {
+ outcome = Err(error as Error);
+ }
+ }
+ } finally {
+ if (outcome.ok) {
+ destruction.resolve();
+ } else {
+ destruction.reject(outcome.error);
+ }
}
- }
- unbox(outcome);
- }
+ unbox(outcome);
+ },
+ }, { at: "min" });
return [scope, destroy];
}
@@ -99,4 +172,9 @@ export function createScopeInternal(
export interface ScopeInternal extends Scope, AsyncDisposable {
contexts: Record;
ensure(op: () => Operation): () => void;
+ reduce(
+ context: Context,
+ fn: (sum: TSum, item: T) => TSum,
+ initial: TSum,
+ ): TSum;
}
diff --git a/lib/types.ts b/lib/types.ts
index 72ed7cf6c..a1f48b10f 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1,3 +1,4 @@
+// deno-lint-ignore-file no-explicit-any
import type { Result } from "./result.ts";
/**
@@ -335,8 +336,98 @@ export interface Scope {
* @returns `true` if scope has its own context, `false` if context is not present, or inherited from its parent.
*/
hasOwn(context: Context): boolean;
+
+ /**
+ * Enhance an {@link Api} within this scope by surrounding it with
+ * middleware.
+ *
+ * @param api - the api being enhanced
+ * @param middlewares - collection of {@link Middleware} to be added to this {@link Api}
+ * @param options - specifies which layer of dispatch `middlewares` will be applied
+ * @see {@link Api.around}
+ * @since 4.1
+ */
+ around(api: Api, ...options: Parameters["around"]>): void;
}
+/**
+ * A set of methods and values that can be decorated on a per-scope
+ * basis. Apis are ideal for situations that require context
+ * sensitivity such as dependency injection, test mocking, and
+ * instrumentation.
+ *
+ * @template A - core shape of the Api
+ * @see {@link createApi}
+ * @since 4.1
+ */
+export interface Api {
+ /**
+ * Every member of `A` "lifted" into an operation that invokes that
+ * member on the current {Scope}
+ */
+ operations: {
+ [K in keyof A]: A[K] extends Operation ? A[K]
+ : A[K] extends (...args: infer TArgs) => infer TReturn
+ ? TReturn extends Operation ? A[K]
+ : (...args: TArgs) => Operation
+ : Operation;
+ };
+ /**
+ * Enhance an {@link Api} within this scope by surrounding it with
+ * middleware.
+ *
+ * @param middlewares - a set of decorators that will surround the api core
+ * @param options - specify at which layer of dispatch, `middleware` will apply
+ * @returns an {Operation} that installs the middleware in the current {Scope}
+ */
+ around: (
+ middlewares: Partial>,
+ options?: {
+ at: "min" | "max";
+ },
+ ) => Operation;
+
+ /**
+ * Call an API as it exists on `scope`.
+ */
+ invoke: (
+ scope: Scope,
+ key: K,
+ args: A[K] extends (...args: any) => unknown ? Parameters : [],
+ ) => A[K] extends (...args: any) => unknown ? ReturnType : A[K];
+}
+
+/**
+ * An general function that can be used to surround any other function
+ * or value.
+ *
+ * @since 4.1
+ */
+export interface Middleware {
+ /**
+ * Execute a single link in the middleware stack by doing whatever
+ * computation is necessary and then optionally delegating to the
+ * next link.
+ *
+ * @param args - the arguments to the value being surrounded.
+ * @param next - the next function in the change. It will accept the
+ * arguments contained in `args`
+ * @returns a value with the same shape as `next()`'s return value
+ */
+ (args: TArgs, next: (...args: TArgs) => TReturn): TReturn;
+}
+
+/**
+ * The shape of middlewares can surround a particular {Api}
+ *
+ * Members of an Api that are values are surrounded by no-arg functions.
+ */
+export type Around = {
+ [K in keyof Api]: Api[K] extends (...args: infer TArgs) => infer TReturn
+ ? Middleware
+ : Middleware<[], Api[K]>;
+};
+
/**
* Unwrap the type of an `Operation`.
* Analogous to the built in [`Awaited`](https://www.typescriptlang.org/docs/handbook/utility-types.html#awaitedtype) type.
diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts
index 51ea9acff..a3f180d22 100644
--- a/tasks/build-npm.ts
+++ b/tasks/build-npm.ts
@@ -1,4 +1,5 @@
-import { build, emptyDir } from "jsr:@deno/dnt@0.41.3";
+import { build, emptyDir } from "jsr:@deno/dnt@0.42.3";
+import denoJSON from "../deno.json" with { type: "json" };
const outDir = "./build/npm";
@@ -10,7 +11,10 @@ if (!version) {
}
await build({
- entryPoints: ["./mod.ts"],
+ entryPoints: Object.entries(denoJSON.exports).map(([key, value]) => ({
+ name: key,
+ path: value,
+ })),
outDir,
shims: {
deno: false,
diff --git a/test/api.test.ts b/test/api.test.ts
new file mode 100644
index 000000000..7c4be2a43
--- /dev/null
+++ b/test/api.test.ts
@@ -0,0 +1,210 @@
+import { run } from "../mod.ts";
+import { createApi } from "../experimental.ts";
+import { constant } from "../lib/constant.ts";
+import { type Operation, spawn } from "../lib/mod.ts";
+import { describe, expect, it } from "./suite.ts";
+
+describe("api", () => {
+ it("invokes operation functions as operations", async () => {
+ let api = createApi("test", {
+ *test() {
+ return 5;
+ },
+ });
+
+ await run(function* () {
+ expect(yield* api.operations.test()).toEqual(5);
+ });
+ });
+
+ it("invokes synchronous functions as operations", async () => {
+ let api = createApi("test", {
+ five: () => 5,
+ });
+
+ await run(function* () {
+ expect(yield* api.operations.five()).toEqual(5);
+ });
+ });
+
+ it("invokes operations as operations", async () => {
+ let api = createApi("test", {
+ five: {
+ *[Symbol.iterator]() {
+ return 5;
+ },
+ } as Operation,
+ });
+
+ await run(function* () {
+ expect(yield* api.operations.five).toEqual(5);
+ });
+ });
+
+ it("invokes constants as operations", async () => {
+ let api = createApi("test", {
+ five: 5,
+ });
+
+ await run(function* () {
+ expect(yield* api.operations.five).toEqual(5);
+ });
+ });
+
+ it("can have middleware installed", async () => {
+ let api = createApi("test", {
+ constFive: 5,
+ *operationFnFive() {
+ return 5;
+ },
+ operationFive: constant(5),
+ syncFive: () => 5 as number,
+ });
+
+ await run(function* () {
+ yield* api.around({
+ constFive(args, next) {
+ return next(...args) * 2;
+ },
+ *operationFnFive(args, next) {
+ return (yield* next(...args)) * 2;
+ },
+ *operationFive(args, next) {
+ return (yield* next(...args)) * 2;
+ },
+ syncFive: (args, next) => next(...args) * 2,
+ });
+
+ expect(yield* api.operations.constFive).toEqual(10);
+ expect(yield* api.operations.operationFnFive()).toEqual(10);
+ expect(yield* api.operations.operationFive).toEqual(10);
+ expect(yield* api.operations.syncFive()).toEqual(10);
+ });
+ });
+
+ it("inherits middleware from scope", async () => {
+ let api = createApi("test", {
+ *num(value: number): Operation {
+ return value;
+ },
+ });
+
+ await run(function* () {
+ yield* api.around({
+ *num(args, next) {
+ return (yield* next(...args)) * 2;
+ },
+ });
+ let task = yield* spawn(function* () {
+ return yield* api.operations.num(5);
+ });
+
+ expect(yield* task).toEqual(10);
+ });
+ });
+
+ it("applies maximal middleware before minimal middleware", async () => {
+ let api = createApi("test", {
+ *test(order: string[]): Operation {
+ return order;
+ },
+ });
+
+ await run(function* () {
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("max1"));
+ return output.concat("/max1");
+ },
+ });
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("max2"));
+ return output.concat("/max2");
+ },
+ });
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("min1"));
+ return output.concat("/min1");
+ },
+ });
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("min2"));
+ return output.concat("/min2");
+ },
+ });
+
+ expect(yield* api.operations.test([])).toEqual([
+ "max1",
+ "max2",
+ "min1",
+ "min2",
+ "/min2",
+ "/min1",
+ "/max2",
+ "/max1",
+ ]);
+ });
+ });
+
+ it("applies outer scope maxima more maximally than inner scopes maxima", async () => {
+ let api = createApi("test", {
+ *test(order: string[]): Operation {
+ return order;
+ },
+ });
+
+ await run(function* outer() {
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("outermax"));
+ return output.concat("/outermax");
+ },
+ });
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("outermin"));
+ return output.concat("/outermin");
+ },
+ }, { at: "min" });
+
+ let task = yield* spawn(function* inner() {
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("innermax"));
+ return output.concat("/innermax");
+ },
+ });
+ yield* api.around({
+ *test(args, next) {
+ let [input] = args;
+ let output = yield* next(input.concat("innermin"));
+ return output.concat("/innermin");
+ },
+ }, { at: "min" });
+
+ return yield* api.operations.test([]);
+ });
+
+ expect(yield* task).toEqual([
+ "outermax",
+ "innermax",
+ "innermin",
+ "outermin",
+ "/outermin",
+ "/innermin",
+ "/innermax",
+ "/outermax",
+ ]);
+ });
+ });
+});
diff --git a/test/attributes.test.ts b/test/attributes.test.ts
new file mode 100644
index 000000000..3933865ae
--- /dev/null
+++ b/test/attributes.test.ts
@@ -0,0 +1,45 @@
+import { run, spawn, useAttributes, useScope } from "../mod.ts";
+import { describe, expect, it } from "./suite.ts";
+import { getAttributes } from "../lib/attributes-internal.ts";
+
+describe("useAttributes", () => {
+ it("adds attributes to the current scope", async () => {
+ let scope = await run(function* main() {
+ yield* useAttributes({ name: "Main", awesome: true });
+
+ return yield* useScope();
+ });
+
+ let attrs = getAttributes(scope);
+
+ expect(attrs).toEqual({ name: "Main", awesome: true });
+ });
+
+ it("does not cause any attributes to be inherited from the parent", async () => {
+ let scope = await run(function* main() {
+ yield* useAttributes({ awesome: true });
+ let child = yield* spawn(function* () {
+ yield* useAttributes({ name: "Child" });
+ return yield* useScope();
+ });
+
+ return yield* child;
+ });
+
+ let attrs = getAttributes(scope);
+
+ expect(attrs).toEqual({ name: "Child" });
+ });
+
+ it("adds new attributes to existing ones", async () => {
+ let scope = await run(function* main() {
+ yield* useAttributes({ name: "Main" });
+ yield* useAttributes({ awesome: true });
+ return yield* useScope();
+ });
+
+ let attrs = getAttributes(scope);
+
+ expect(attrs).toEqual({ name: "Main", awesome: true });
+ });
+});