0
\$\begingroup\$

The compiler provides a synchronous use() method which accepts a middleware - that is a function that creates any services it wants, the use method than returns new compiler.

When client fed the compiler with all the middleware he wanted, he calls the asynchronous compile() method. The compile method returns a Promise that resolves to union of all types returned by all the middlewares.

The middlewares are called in order and each is awaited (if async) before proceeding to next middleware. Each time a middleware returns new services, they are added to the context of the compiler and the new context is then passed to the next middleware.

Alternatively, client can use replace() method to replace the current context of the compiler. The replace middleware looks the same as use middleware, but the returned services are not added to the current context, they replace it. A new compiler is than returned from the replace methods, this compiler holds only the new services as its current context. The replace() method is provided mostly for special cases.

An example usage looks like this:

const app = await compileApp(compiler => compiler
    .use(() => {
        return { foo: 'jar' }
    })
    .use(({ foo }) => {
        return Promise.resolve({ zoo: 'jar' + foo })
    })
    .use(({ foo }) => {
        console.log(foo) // "jar"

        // use() middleware need not return anything
        // nothing will be added to context
        // doing this in replace() middleware is not allowed
    })
    .replace(ctx => omit(ctx, 'foo'))
)

console.log(app.zoo) // "jarjar"
console.log(app.foo) // undefined

function compileApp<T>(init: (compiler: Compiler<{}>) => Compiler<T>) {
    return init(newCompiler()).compile()
}

Tests are passing.

PASS  test/compiler.spec.ts
  ✓ new compiler compiles empty object
  ✓ compiling single sync extension resolves to whatever that extension middleware returns
  ✓ compiling single async extension resolves to whatever the promise returned by that extension middleware resolves to
  ✓ compiling sync extensions one after the other resolves to their output contexts merged together
  ✓ compiling async extension after sync extension resolves to the input context merged with the resolved output context
  ✓ used extension gets previous context on input
  ✓ compiling sync extensions does not conflict on namespaces
  ✓ compiling async extension after sync extension does not conflict on namespaces
  ✓ replacing single sync extension is the same as using it
  ✓ replacing single async extension is the same as using it
  ✓ replacing by sync extension resolves to whatever the last extension middleware returns
  ✓ replacing by async extension resolves to whatever the promise returned by the last extension middleware resolves to
  ✓ replacing extension middleware gets the previous sync middleware's output context as its input context
  ✓ replacing extension middleware gets the previous async middleware's resolved output context as its input context

test/compiler.spec.ts

import { newCompiler } from '../src/compiler'

test('new compiler compiles empty object', async () => {
    const compiler = newCompiler()

    const app: {} = await compiler.compile()

    expect(app).toEqual({})
})

test('compiling single sync extension resolves to whatever that extension middleware returns', async () => {
    const compiler = newCompiler()
    const ext = { service: 'dummy' }

    const app: typeof ext = await compiler
        .use(() => ext)
        .compile()

    expect(app).toEqual(ext)
})

test('compiling single async extension resolves to whatever the promise returned by that extension middleware resolves to', async () => {
    const compiler = newCompiler()
    const ext = { service: 'dummy' }

    const app: typeof ext = await compiler
        .use(() => Promise.resolve(ext))
        .compile()

    expect(app).toEqual(ext)
})

test('compiling sync extensions one after the other resolves to their output contexts merged together', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    const app: typeof ext1 & typeof ext2 = await compiler
        .use(() => ext1)
        .use(() => ext2)
        .compile()

    expect(app).toEqual({ ...ext1, ...ext2 })
})

test('compiling async extension after sync extension resolves to the input context merged with the resolved output context', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    const app: typeof ext1 & typeof ext2 = await compiler
        .use(() => ext1)
        .use(() => Promise.resolve(ext2))
        .compile()

    expect(app).toEqual({ ...ext1, ...ext2 })
})

test('used extension gets previous context on input', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    let inputContextOfExt2: typeof ext1
    const app: typeof ext1 = await compiler
        .use(() => ext1)
        .use((ctx) => {
            inputContextOfExt2 = ctx
        })
        .compile()

    expect(inputContextOfExt2).toEqual(ext1)
})

test('compiling sync extensions does not conflict on namespaces', async () => {
    const compiler = newCompiler()
    const ext1 = { ns: { service1: 'dummy' } }
    const ext2 = { ns: { service2: 'dummy' } }

    const app: typeof ext1 & typeof ext2 = await compiler
        .use(() => ext1)
        .use(() => ext2)
        .compile()

    expect(app).toEqual({ ns: { ...ext1.ns, ...ext2.ns } })
})

test('compiling async extension after sync extension does not conflict on namespaces', async () => {
    const compiler = newCompiler()
    const ext1 = { ns: { service1: 'dummy' } }
    const ext2 = { ns: { service2: 'dummy' } }

    const app: typeof ext1 & typeof ext2 = await compiler
        .use(() => ext1)
        .use(() => Promise.resolve(ext2))
        .compile()

    expect(app).toEqual({ ns: { ...ext1.ns, ...ext2.ns } })
})

test('replacing single sync extension is the same as using it', async () => {
    const compiler = newCompiler()
    const ext = { service: 'dummy' }

    const app: typeof ext = await compiler
        .replace(() => ext)
        .compile()

    expect(app).toEqual(ext)
})

test('replacing single async extension is the same as using it', async () => {
    const compiler = newCompiler()
    const ext = { service: 'dummy' }

    const app: typeof ext = await compiler
        .replace(() => Promise.resolve(ext))
        .compile()

    expect(app).toEqual(ext)
})

test('replacing by sync extension resolves to whatever the last extension middleware returns', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    const app: typeof ext2 = await compiler
        .use(() => ext1)
        .replace(() => ext2)
        .compile()

    expect(app).toEqual(ext2)
})

test('replacing by async extension resolves to whatever the promise returned by the last extension middleware resolves to', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    const app: typeof ext2 = await compiler
        .use(() => ext1)
        .replace(() => Promise.resolve(ext2))
        .compile()

    expect(app).toEqual(ext2)
})

test('replacing extension middleware gets the previous sync middleware\'s output context as its input context', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    let inputContextOfExt2: typeof ext1

    await compiler
        .use(() => ext1)
        .replace(ctx => {
            inputContextOfExt2 = ctx
            return ext2
        })
        .compile()

    expect(inputContextOfExt2).toEqual(ext1)
})

test('replacing extension middleware gets the previous async middleware\'s resolved output context as its input context', async () => {
    const compiler = newCompiler()
    const ext1 = { service1: 'dummy' }
    const ext2 = { service2: 'dummy' }

    let inputContextOfExt2: typeof ext1

    await compiler
        .use(() => Promise.resolve(ext1))
        .replace(ctx => {
            inputContextOfExt2 = ctx
            return ext2
        })
        .compile()

    expect(inputContextOfExt2).toEqual(ext1)
})

src/compiler.ts

import merge from "merge"

export class Compiler<T> {
    constructor(
        private readonly ctxPromise: Promise<T>,
    ) {
    }

    replace<E>(middleware: (ctx: T) => E): Compiler<E | Awaited<E>> {
        return new Compiler(this.ctxPromise.then(middleware))
    }

    use<E>(middleware: (ctx: T) => E): MergedCompiler<T, E> {
        return new Compiler(this.ctxPromise.then((ctx) => {
            const ext = middleware(ctx)
            if (ext instanceof Promise) {
                return ext.then(x => merge.recursive({}, ctx, x))
            } else {
                return merge.recursive({}, ctx, ext)
            }
        })) as MergedCompiler<T, E>
    }

    compile(): Promise<T> {
        return this.ctxPromise
    }
}

export function newCompiler() {
    return new Compiler(Promise.resolve({}))
}

type MergedCompiler<T, E, C = E extends void | Promise<void> ? Compiler<T> : Compiler<T & Awaited<E>>> = C

I don't like the typing of use() and replace(). The E type is probably too wide and allows more then it should, possibly it could resolve incorrectly in some edge case. But it's too messy for my liking so I'm not sure. I don't need to allow more then void, Promise<void>, object or Promise<object>, but not necesery PromiseLike, but maybe it is unavoidable?. void and Promise<void> need not be supported for replace(). Things like numbers, strings etc need not be supported at all. I am especially suspicious becuase if I remove the as MergedCompiler cast, it spits some large complain about the typing that I don't quite understand.

The recursive merge method may be replaced in future, I just wanted something quickly working for now. And this is not my concern now.

The simpler the typing will be the more friendly it wil be to code inspection of IDE but it must still be robust. What I am a bit worried is whether it can scale with many extensions.

Also I am curious if it could be implemented in a way that use() would call replace(), it seems like it should be possible and dry, but I couldn't get the typing correct when I tried - that's also the reason why I suspect the typing is not entirely ok as it is now...

EDIT:

I managed to somewhat improve the typing by the following change. However please comment on anything you feel relevant

type ExtendedContext<T, E, C = E extends void | Promise<void> ? T : (T & Awaited<E>)> = C
    use<E>(middleware: (ctx: T) => E): Compiler<ExtendedContext<T, E>> {
        return new Compiler(this.ctxPromise.then((ctx) => {
            const ext = middleware(ctx)
            if (ext instanceof Promise) {
                return ext.then(x => merge.recursive({}, ctx, x))
            } else {
                return merge.recursive({}, ctx, ext)
            }
        }))
    }
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.