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 }); + }); +});