diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 0000000..038129a --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,26 @@ +name: Deno CI + +on: [push, pull_request] + +jobs: + test: + runs-on: "ubuntu-latest" + steps: + - name: Setup repo + uses: actions/checkout@v2 + + - name: Setup Deno + uses: denolib/setup-deno@v2 + with: + deno-version: v1.x + + - name: Install Node.js 14 + uses: actions/setup-node@v1 + with: + node-version: 14 + + - name: Install build tooling + run: npm install + + - name: Run Tests + run: npm run test:deno diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 038f0e7..4922f81 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,3 +1,4 @@ + name: CI on: [push, pull_request] @@ -9,7 +10,7 @@ jobs: strategy: matrix: - node-version: [10.x, 12.x, 13.x] + node-version: [13.x, 15.x] steps: - uses: actions/checkout@v2 @@ -21,4 +22,4 @@ jobs: - run: npm install - run: npm test env: - CI: true + CI: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 875e165..a85cb0d 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,5 @@ typings/ # Build folders lib/ -deno/ dist/ *.sqlite diff --git a/package.json b/package.json index 0049dee..4ddb726 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "install": "lerna bootstrap", "publish": "lerna publish && git push origin master", "lint": "tslint 'packages/**/src/*.ts' 'packages/**/test/*.ts' -c tslint.json --fix", - "test": "npm run lint && nyc lerna run test" + "test": "npm run lint && nyc lerna run test", + "test:deno": "lerna run test:deno" }, "devDependencies": { "lerna": "^4.0.0", diff --git a/packages/hooks/build/deno.js b/packages/hooks/build/deno.js index a84cce9..f642b34 100644 --- a/packages/hooks/build/deno.js +++ b/packages/hooks/build/deno.js @@ -3,10 +3,8 @@ const path = require('path'); const fs = require('fs'); -const moduleNames = [ - './base', './compose', './decorator', - './function', './object' -]; +const moduleNames = fs.readdirSync(path.join(__dirname, '..', 'src')) + .map(mod => `./${mod.replace('.ts', '')}`); const folder = path.join(__dirname, '..', 'src'); const out = path.join(__dirname, '..', 'deno'); diff --git a/packages/hooks/deno/base.ts b/packages/hooks/deno/base.ts new file mode 100644 index 0000000..d35a879 --- /dev/null +++ b/packages/hooks/deno/base.ts @@ -0,0 +1,210 @@ +import { Middleware } from './compose.ts'; +import { copyToSelf, copyProperties } from './utils.ts'; + +export const HOOKS: string = Symbol('@feathersjs/hooks') as any; + +export type HookContextData = { [key: string]: any }; + +/** + * The base hook context. + */ +export class HookContext { + result?: T; + method?: string; + self: C; + arguments: any[]; + [key: string]: any; + + constructor (data: HookContextData = {}) { + Object.assign(this, data); + } +} + +export type HookContextConstructor = new (data?: { [key: string]: any }) => HookContext; + +export type HookDefaultsInitializer = (self?: any, args?: any[], context?: HookContext) => HookContextData; + +export class HookManager { + _parent?: this|null = null; + _params: string[]|null = null; + _middleware: Middleware[]|null = null; + _props: HookContextData|null = null; + _defaults: HookDefaultsInitializer; + + parent (parent: this) { + this._parent = parent; + + return this; + } + + middleware (middleware?: Middleware[]) { + this._middleware = middleware?.length ? middleware : null; + + return this; + } + + getMiddleware (): Middleware[]|null { + const previous = this._parent?.getMiddleware(); + + if (previous && this._middleware) { + return previous.concat(this._middleware); + } + + return previous || this._middleware; + } + + collectMiddleware (self: any, _args: any[]): Middleware[] { + const otherMiddleware = getMiddleware(self); + const middleware = this.getMiddleware(); + + if (otherMiddleware && middleware) { + return otherMiddleware.concat(middleware); + } + + return otherMiddleware || middleware; + } + + props (props: HookContextData) { + if (!this._props) { + this._props = {}; + } + + copyProperties(this._props, props); + + return this; + } + + getProps (): HookContextData { + const previous = this._parent?.getProps(); + + if (previous && this._props) { + return copyProperties({}, previous, this._props); + } + + return previous || this._props; + } + + params (...params: string[]) { + this._params = params; + + return this; + } + + getParams (): string[] { + const previous = this._parent?.getParams(); + + if (previous && this._params) { + return previous.concat(this._params); + } + + return previous || this._params; + } + + defaults (defaults: HookDefaultsInitializer) { + this._defaults = defaults; + + return this; + } + + getDefaults (self: any, args: any[], context: HookContext): HookContextData { + const defaults = typeof this._defaults === 'function' ? this._defaults(self, args, context) : null; + const previous = this._parent?.getDefaults(self, args, context); + + if (previous && defaults) { + return Object.assign({}, previous, defaults); + } + + return previous || defaults; + } + + getContextClass (Base: HookContextConstructor = HookContext): HookContextConstructor { + const ContextClass = class ContextClass extends Base { + constructor (data: any) { + super(data); + + copyToSelf(this); + } + }; + const params = this.getParams(); + const props = this.getProps(); + + if (params) { + params.forEach((name, index) => { + if (props?.[name] !== undefined) { + throw new Error(`Hooks can not have a property and param named '${name}'. Use .defaults instead.`); + } + + Object.defineProperty(ContextClass.prototype, name, { + enumerable: true, + get () { + return this?.arguments[index]; + }, + set (value: any) { + this.arguments[index] = value; + } + }); + }); + } + + if (props) { + copyProperties(ContextClass.prototype, props); + } + + return ContextClass; + } + + initializeContext (self: any, args: any[], context: HookContext): HookContext { + const ctx = this._parent ? this._parent.initializeContext(self, args, context) : context; + const defaults = this.getDefaults(self, args, ctx); + + if (self) { + ctx.self = self; + } + + ctx.arguments = args; + + if (defaults) { + for (const name of Object.keys(defaults)) { + if (ctx[name] === undefined) { + ctx[name] = defaults[name]; + } + } + } + + return ctx; + } +} + +export type HookOptions = HookManager|Middleware[]|null; + +export function convertOptions (options: HookOptions = null) { + if (!options) { + return new HookManager() + } + + return Array.isArray(options) ? new HookManager().middleware(options) : options; +} + +export function getManager (target: any): HookManager|null { + return (target && target[HOOKS]) || null; +} + +export function setManager (target: T, manager: HookManager) { + const parent = getManager(target); + + (target as any)[HOOKS] = manager.parent(parent); + + return target; +} + +export function getMiddleware (target: any): Middleware[]|null { + const manager = getManager(target); + + return manager ? manager.getMiddleware() : null; +} + +export function setMiddleware (target: T, middleware: Middleware[]) { + const manager = new HookManager().middleware(middleware); + + return setManager(target, manager); +} diff --git a/packages/hooks/deno/compose.ts b/packages/hooks/deno/compose.ts new file mode 100644 index 0000000..173cc50 --- /dev/null +++ b/packages/hooks/deno/compose.ts @@ -0,0 +1,47 @@ +// TypeScript port of koa-compose (https://github.com/koajs/compose) +export type NextFunction = () => Promise; + +export type Middleware = (context: T, next: NextFunction) => Promise; + +export function compose (middleware: Middleware[]) { + if (!Array.isArray(middleware)) { + throw new TypeError('Middleware stack must be an array!'); + } + + for (const fn of middleware) { + if (typeof fn !== 'function') { + throw new TypeError('Middleware must be composed of functions!'); + } + } + + return function (this: any, context: T, next?: Middleware) { + // last called middleware # + let index: number = -1; + + return dispatch.call(this, 0); + + function dispatch (this: any, i: number): Promise { + if (i <= index) { + return Promise.reject(new Error('next() called multiple times')); + } + + index = i; + + let fn = middleware[i]; + + if (i === middleware.length) { + fn = next; + } + + if (!fn) { + return Promise.resolve(); + } + + try { + return Promise.resolve(fn.call(this, context, dispatch.bind(this, i + 1))); + } catch (err) { + return Promise.reject(err); + } + } + }; +} diff --git a/packages/hooks/deno/hooks.ts b/packages/hooks/deno/hooks.ts new file mode 100644 index 0000000..c5f33d7 --- /dev/null +++ b/packages/hooks/deno/hooks.ts @@ -0,0 +1,114 @@ +import { compose, Middleware } from './compose.ts'; +import { + HookContext, setManager, HookContextData, HookOptions, convertOptions, setMiddleware +} from './base.ts'; +import { copyProperties } from './utils.ts'; + +export function getOriginal (fn: any): any { + return typeof fn.original === 'function' ? getOriginal(fn.original) : fn; +} + +export function functionHooks (fn: F, managerOrMiddleware: HookOptions) { + if (typeof fn !== 'function') { + throw new Error('Can not apply hooks to non-function'); + } + + const manager = convertOptions(managerOrMiddleware); + const wrapper: any = function (this: any, ...args: any[]) { + const { Context, original } = wrapper; + // If we got passed an existing HookContext instance, we want to return it as well + const returnContext = args[args.length - 1] instanceof Context; + // Use existing context or default + const base = returnContext ? (args.pop() as HookContext) : new Context(); + // Initialize the context + const context = manager.initializeContext(this, args, base); + // Assemble the hook chain + const hookChain: Middleware[] = [ + // Return `ctx.result` or the context + (ctx, next) => next().then(() => returnContext ? ctx : ctx.result) + ]; + + // Create the hook chain by calling the `collectMiddleware function + const mw = manager.collectMiddleware(this, args); + + if (mw) { + Array.prototype.push.apply(hookChain, mw); + } + + // Runs the actual original method if `ctx.result` is not already set + hookChain.push((ctx, next) => { + if (!Object.prototype.hasOwnProperty.call(context, 'result')) { + return Promise.resolve(original.apply(this, ctx.arguments)).then(result => { + ctx.result = result; + + return next(); + }); + } + + return next(); + }); + + return compose(hookChain).call(this, context); + }; + + copyProperties(wrapper, fn); + setManager(wrapper, manager); + + return Object.assign(wrapper, { + original: getOriginal(fn), + Context: manager.getContextClass(), + createContext: (data: HookContextData = {}) => { + return new wrapper.Context(data); + } + }); +}; + +export type HookMap = { + [L in keyof O]?: HookOptions; +} + +export function objectHooks (_obj: any, hooks: HookMap|Middleware[]) { + const obj = typeof _obj === 'function' ? _obj.prototype : _obj; + + if (Array.isArray(hooks)) { + return setMiddleware(obj, hooks); + } + + return Object.keys(hooks).reduce((result, method) => { + const fn = obj[method]; + + if (typeof fn !== 'function') { + throw new Error(`Can not apply hooks. '${method}' is not a function`); + } + + const manager = convertOptions(hooks[method]); + + result[method] = functionHooks(fn, manager.props({ method })); + + return result; + }, obj); +}; + +export const hookDecorator = (managerOrMiddleware?: HookOptions) => { + const wrapper: any = (_target: any, method: string, descriptor: TypedPropertyDescriptor): TypedPropertyDescriptor => { + const manager = convertOptions(managerOrMiddleware); + + if (!descriptor) { + setManager(_target.prototype, manager); + + return _target; + } + + const fn = descriptor.value; + + if (typeof fn !== 'function') { + throw new Error(`Can not apply hooks. '${method}' is not a function`); + } + + descriptor.value = functionHooks(fn, manager.props({ method })); + + return descriptor; + }; + + return wrapper; +}; diff --git a/packages/hooks/deno/index.ts b/packages/hooks/deno/index.ts new file mode 100644 index 0000000..8d94a7b --- /dev/null +++ b/packages/hooks/deno/index.ts @@ -0,0 +1,90 @@ +import { Middleware } from './compose.ts'; +import { + HookManager, HookContextData, HookContext, HookContextConstructor, HookOptions +} from './base.ts'; +import { functionHooks, hookDecorator, objectHooks, HookMap } from './hooks.ts'; + +export * from './hooks.ts'; +export * from './compose.ts'; +export * from './base.ts'; + +export interface WrapperAddon { + original: F; + Context: HookContextConstructor; + createContext: (data?: HookContextData) => HookContext; +} + +export type WrappedFunction = F&((...rest: any[]) => Promise|Promise)&WrapperAddon; + +export type MiddlewareOptions = { + params?: any; + defaults?: any; + props?: any; +}; + +/** + * Initializes a hook settings object with the given middleware. + * @param mw The list of middleware + */ +export function middleware (mw?: Middleware[], options?: MiddlewareOptions) { + const manager = new HookManager().middleware(mw); + + if (options) { + if (options.params) { + manager.params(...options.params); + } + + if (options.defaults) { + manager.defaults(options.defaults); + } + + if (options.props) { + manager.props(options.props); + } + } + + return manager; +} + +/** + * Returns a new function that wraps an existing async function + * with hooks. + * + * @param fn The async function to add hooks to. + * @param manager An array of middleware or hook settings + * (`middleware([]).params()` etc.) + */ +export function hooks ( + fn: F, manager: HookManager +): WrappedFunction; + +/** + * Add hooks to one or more methods on an object or class. + * @param obj The object to add hooks to + * @param hookMap A map of middleware settings where the + * key is the method name. + */ +export function hooks (obj: O|(new (...args: any[]) => O), hookMap: HookMap|Middleware[]): O; + +/** + * Decorate a class method with hooks. + * @param _manager The hooks settings + */ +export function hooks ( + _manager?: HookOptions +): any; + +// Fallthrough to actual implementation +export function hooks (...args: any[]) { + const [ target, _hooks ] = args; + + if (typeof target === 'function' && (_hooks instanceof HookManager || Array.isArray(_hooks))) { + return functionHooks(target, _hooks); + } + + if (args.length === 2) { + return objectHooks(target, _hooks); + } + + return hookDecorator(target); +} diff --git a/packages/hooks/deno/utils.ts b/packages/hooks/deno/utils.ts new file mode 100644 index 0000000..936cac9 --- /dev/null +++ b/packages/hooks/deno/utils.ts @@ -0,0 +1,47 @@ +const proto = Object.prototype as any; +// These are non-standard but offer a more reliable prototype based +// lookup for properties +const hasProtoDefinitions = typeof proto.__lookupGetter__ === 'function' && + typeof proto.__defineGetter__ === 'function' && + typeof proto.__defineSetter__ === 'function'; + +export function copyToSelf (target: any) { + // tslint:disable-next-line + for (const key in target) { + if (!target.hasOwnProperty(key)) { + const getter = hasProtoDefinitions ? target.constructor.prototype.__lookupGetter__(key) + : Object.getOwnPropertyDescriptor(target, key); + + if (hasProtoDefinitions && getter) { + target.__defineGetter__(key, getter); + + const setter = target.constructor.prototype.__lookupSetter__(key); + + if (setter) { + target.__defineSetter__(key, setter); + } + } else if (getter) { + Object.defineProperty(target, key, getter); + } else { + target[key] = target[key]; + } + } + } +} + +export function copyProperties (target: F, ...originals: any[]) { + for (const original of originals) { + const originalProps = (Object.keys(original) as any) + .concat(Object.getOwnPropertySymbols(original)); + + for (const prop of originalProps) { + const propDescriptor = Object.getOwnPropertyDescriptor(original, prop); + + if (!target.hasOwnProperty(prop)) { + Object.defineProperty(target, prop, propDescriptor); + } + } + } + + return target; +} diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 4c14680..c0bfd0f 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -31,11 +31,13 @@ "types": "lib/", "scripts": { "build:browser": "webpack --config build/webpack.config.js", - "build:deno": "shx mkdir -p deno && node build/deno", + "build": "npm run compile && npm run build:browser", "compile": "shx rm -rf lib/ && tsc", - "build": "npm run compile && npm run build:deno && npm run build:browser", "prepublish": "npm run build", - "test": "mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts" + "test:mocha": "mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts", + "test": "npm run build && npm run test:mocha", + "build:deno": "shx rm -rf deno && shx mkdir -p deno && node build/deno", + "test:deno": "npm run build:deno && deno test --config tsconfig.json test/deno_test.ts" }, "directories": { "lib": "lib" @@ -53,15 +55,15 @@ "access": "public" }, "devDependencies": { - "@types/chai": "^4.2.14", - "@types/mocha": "^8.0.3", - "@types/node": "^14.14.6", - "mocha": "^8.2.1", + "@types/chai": "^4.2.15", + "@types/mocha": "^8.2.2", + "@types/node": "^14.14.37", + "mocha": "^8.3.2", "shx": "^0.3.3", - "ts-loader": "^8.0.10", - "ts-node": "^9.0.0", - "typescript": "^4.0.5", - "webpack": "^5.4.0", - "webpack-cli": "^4.2.0" + "ts-loader": "^8.1.0", + "ts-node": "^9.1.1", + "typescript": "^4.2.3", + "webpack": "^5.28.0", + "webpack-cli": "^4.6.0" } } diff --git a/packages/hooks/test/deno_test.ts b/packages/hooks/test/deno_test.ts new file mode 100644 index 0000000..d7490d7 --- /dev/null +++ b/packages/hooks/test/deno_test.ts @@ -0,0 +1,62 @@ +import { assertEquals } from 'https://deno.land/std@0.91.0/testing/asserts.ts'; + +import { hooks, HookContext, NextFunction, middleware } from '../deno/index.ts'; + +const hello = async (name?: string, _params: any = {}) => { + return `Hello ${name}`; +}; + +class DummyClass { + async sayHi (name: string) { + return `Hi ${name}`; + } + + async addOne (number: number) { + return number + 1; + } +} + +Deno.test('can override context.result after', async () => { + const updateResult = async (ctx: HookContext, next: NextFunction) => { + await next(); + + ctx.result += ' You!'; + }; + + const fn = hooks(hello, [ updateResult ]); + const res = await fn('There'); + + assertEquals(res, 'Hello There You!'); +}); + +Deno.test('hooking object on class adds to the prototype', async () => { + hooks(DummyClass, { + sayHi: middleware([ + async (ctx: HookContext, next: NextFunction) => { + assertEquals(ctx, new (DummyClass.prototype.sayHi as any).Context({ + arguments: ['David'], + method: 'sayHi', + name: 'David', + self: instance + })); + + await next(); + + ctx.result += '?'; + } + ]).params('name'), + + addOne: middleware([ + async (ctx: HookContext, next: NextFunction) => { + ctx.arguments[0] += 1; + + await next(); + } + ]) + }); + + const instance = new DummyClass(); + + assertEquals(await instance.sayHi('David'), 'Hi David?'); + assertEquals(await instance.addOne(1), 3); +}); diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json index bfcd97c..6a14937 100644 --- a/packages/hooks/tsconfig.json +++ b/packages/hooks/tsconfig.json @@ -5,6 +5,7 @@ ], "compilerOptions": { "outDir": "lib", - "experimentalDecorators": true + "experimentalDecorators": true, + "strictNullChecks": false } } diff --git a/readme.md b/readme.md index 5c36eaa..07db438 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,7 @@

@feathersjs/hooks

-[![CI GitHub action](https://github.com/feathersjs/hooks/workflows/Node%20CI/badge.svg)](https://github.com/feathersjs/hooks/actions?query=workflow%3A%22Node+CI%22) +[![Node CI](https://github.com/feathersjs/hooks/workflows/Node%20CI/badge.svg)](https://github.com/feathersjs/hooks/actions?query=workflow%3A%22Node+CI%22) +[![Deno CI](https://github.com/feathersjs/hooks/actions/workflows/deno.yml/badge.svg)](https://github.com/feathersjs/hooks/actions/workflows/deno.yml) `@feathersjs/hooks` brings middleware to any async JavaScript or TypeScript function. It allows to create composable and reusable workflows that can add