Skip to content

Commit

Permalink
add Express.js forking recipe; rework onFork hook to allow forking …
Browse files Browse the repository at this point in the history
…services to run the forked context inside a callback, thus enabling services to use AsyncLocalStorage
  • Loading branch information
jahudka committed Sep 7, 2023
1 parent 88b8200 commit f04725a
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 30 deletions.
4 changes: 2 additions & 2 deletions core/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ioc",
"inversion of control"
],
"version": "0.0.36",
"version": "0.0.37",
"license": "MIT",
"author": {
"name": "Dan Kadera",
Expand All @@ -34,7 +34,7 @@
"dependencies": {
"@debugr/console": "^3.0.0-rc.10",
"@debugr/core": "^3.0.0-rc.12",
"dicc": "^0.0.27",
"dicc": "^0.0.28",
"ts-morph": "^18.0",
"typescript": "^5.0",
"yaml": "^2.3.1",
Expand Down
44 changes: 28 additions & 16 deletions core/cli/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,53 +261,65 @@ export class Compiler {
info: CallbackInfo | undefined,
decorators: DecoratorInfo[],
): void {
const params = this.compileParameters(info?.parameters ?? []);
const params = ['service', ...this.compileParameters(info?.parameters ?? [])];
const decParams = decorators.map(([,, info]) => this.compileParameters(info.parameters));
const inject = params.length > 0 || decParams.some((p) => p.length > 0);
const inject = params.length > 1 || decParams.some((p) => p.length > 0);
const async = info?.async || decorators.some(([,, info]) => info.async);

writer.write(`${hook}: `);
writer.conditionalWrite(async, 'async ');
writer.write(`(service`);
writer.write(`(`);
writer.conditionalWrite(hook === 'onFork', 'callback, ');
writer.write(`service`);
writer.conditionalWrite(inject, ', di');
writer.write(') => ');

if (!decorators.length) {
this.compileCall(writer, join('.', source, path, hook), ['service', ...params]);
this.compileCall(writer, join('.', source, path, hook), ['callback', ...params]);
writer.write(',\n');
return;
}

writer.write('{\n');

writer.indent(() => {
let service = 'service';

if (info) {
if (hook === 'onFork') {
writer.write('const fork = ');
service = 'fork ?? service';
const tmp = new CodeBlockWriter(writer.getOptions());
tmp.write('async (fork) => {\n');
tmp.indent(() => {
this.compileDecoratorCalls(tmp, decorators, 'fork ?? service', hook, decParams);
tmp.write('return callback(fork);\n');
});
tmp.write('}');
params.unshift(tmp.toString());
}

this.compileCall(writer, join(' ', info?.async && 'await', join('.', source, path, hook)), ['service', ...params]);
this.compileCall(writer, join(' ', hook === 'onFork' ? 'return' : info.async && 'await', join('.', source, path, hook)), params);
writer.write(';\n');
}

for (const [i, [source, path, info]] of decorators.entries()) {
writer.conditionalWrite((i > 0 ? decParams[i - 1] : params).length > 0 || decParams[i].length > 0, '\n');
this.compileCall(writer, join(' ', info.async && 'await', join('.', source, path, hook)), [service, ...decParams[i]]);
writer.write(';\n');
if (!info || hook !== 'onFork') {
writer.conditionalWrite(params.length > 1 || decParams[0].length > 0, '\n');
this.compileDecoratorCalls(writer, decorators, 'service', hook, decParams);
}

if (hook === 'onFork') {
writer.conditionalWrite(decParams[decParams.length - 1].length > 0, '\n');
writer.write(`return ${info ? 'fork' : 'undefined'};\n`);
if (!info && hook === 'onFork') {
writer.write('return callback();\n');
}
});

writer.write('},\n');
}

private compileDecoratorCalls(writer: CodeBlockWriter, decorators: DecoratorInfo[], service: string, hook: string, decParams: string[][]): void {
for (const [i, [source, path, info]] of decorators.entries()) {
writer.conditionalWrite(i > 0 && (decParams[i - 1].length > 0 || decParams[i].length > 0), '\n');
this.compileCall(writer, join(' ', info.async && 'await', join('.', source, path, hook)), [service, ...decParams[i]]);
writer.write(';\n');
}
}

private compileCall(writer: CodeBlockWriter, expression: string, params: string[]): void {
writer.write(expression);
writer.write('(');
Expand Down
2 changes: 1 addition & 1 deletion core/cli/src/definitionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export class DefinitionScanner {

private resolveServiceHook(definition: ObjectLiteralExpression, hook: string): CallbackInfo | undefined {
const hookProp = definition.getProperty(hook);
const info = this.resolveCallbackInfo(hookProp, 1);
const info = this.resolveCallbackInfo(hookProp, hook === 'onFork' ? 2 : 1);

if (!info && hookProp) {
throw new Error(`Invalid '${hook}' hook, must be a method declaration or property assignment`);
Expand Down
2 changes: 1 addition & 1 deletion core/dicc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ioc",
"inversion of control"
],
"version": "0.0.27",
"version": "0.0.28",
"license": "MIT",
"author": {
"name": "Dan Kadera",
Expand Down
19 changes: 12 additions & 7 deletions core/dicc/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class Container<Services extends Record<string, any> = {}> {
private readonly aliases: Map<string, string[]> = new Map();
private readonly globalServices: Store = new Store();
private readonly localServices: AsyncLocalStorage<Store> = new AsyncLocalStorage();
private readonly forkHooks: Map<string, CompiledServiceForkHook<any>> = new Map();
private readonly forkHooks: [string, CompiledServiceForkHook<any>][] = [];
private readonly creating: Set<string> = new Set();

constructor(definitions: CompiledServiceDefinitionMap<Services>) {
Expand Down Expand Up @@ -101,14 +101,19 @@ export class Container<Services extends Record<string, any> = {}> {
async fork<R>(cb: () => Promise<R>): Promise<R> {
const parent = this.currentStore;
const store = new Store(parent);
const chain = this.forkHooks.reduceRight((next, [id, hook]) => {
return async () => {
const callback = async (fork?: any) => {
fork && store.set(id, fork);
return next();
};

for (const [id, hook] of this.forkHooks) {
const fork = await hook(await this.get(id), this);
fork && store.set(id, fork);
}
return hook(callback, await this.get(id), this);
};
}, (async () => this.localServices.run(store, cb)) as () => Promise<any>);

try {
return await this.localServices.run(store, cb);
return await chain();
} finally {
for (const [id, service] of store) {
const definition = this.definitions.get(id);
Expand Down Expand Up @@ -137,7 +142,7 @@ export class Container<Services extends Record<string, any> = {}> {
for (const [id, definition] of Object.entries(definitions)) {
this.definitions.set(id, definition)
this.aliases.set(id, [id]);
definition.onFork && this.forkHooks.set(id, definition.onFork);
definition.onFork && this.forkHooks.push([id, definition.onFork]);

for (const alias of definition.aliases ?? []) {
this.aliases.has(alias) || this.aliases.set(alias, []);
Expand Down
5 changes: 3 additions & 2 deletions core/dicc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type IterateResult<Services extends Record<string, any>, K extends keyof

export type ServiceScope = 'global' | 'local' | 'private';
export type ServiceHook<T> = (service: T, ...args: any[]) => Promise<void> | void;
export type ServiceForkHook<T> = (service: T, ...args: any[]) => Promise<T | undefined> | T | undefined;
export type ServiceForkHook<T> = (callback: ServiceForkCallback<T, unknown>, service: T, ...args: any[]) => Promise<unknown> | unknown;
export type ServiceForkCallback<T, R> = (fork?: T | undefined) => Promise<R> | R;

export type ServiceDefinitionOptions<T = any> = {
factory: Constructor<T> | Factory<Promise<T | undefined> | T | undefined> | undefined;
Expand Down Expand Up @@ -69,7 +70,7 @@ export type CompiledAsyncServiceHook<T, Services extends Record<string, any> = {
};

export type CompiledServiceForkHook<T, Services extends Record<string, any> = {}> = {
(service: T, container: Container<Services>): Promise<T | undefined> | T | undefined;
(callback: ServiceForkCallback<T, unknown>, service: T, container: Container<Services>): Promise<unknown> | unknown;
};

export type CompiledServiceDefinitionOptions<T = any, Services extends Record<string, any> = {}> = {
Expand Down
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ npm i --save dicc
- [Config and compilation][4] - how to configure the compiler, how to compile
a container and how to use the container at runtime

## Integration recipes

- [Express][5]

## Developer documentation

Expand All @@ -44,3 +47,4 @@ to DICC or to extend it with custom functionality. Coming soon!
[2]: user/02-intro-to-dicc.md
[3]: user/03-services-and-dependencies.md
[4]: user/04-config-and-compilation.md
[5]: recipes/01-express.md
88 changes: 88 additions & 0 deletions docs/recipes/01-express.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Using `container.fork()` with Express

The sadly ubiquitous Express HTTP server doesn't mesh well with async code.
This means some care must be taken when integrating DICC into an Express-based
application, otherwise container forking and local services won't work as
expected.

## TL;DR - how do I use DICC with Express?

Just use the following code snippet as your very first middleware:

```typescript
app.use((req, res, next) => {
container.fork(async () => {
next();

if (!res.closed) {
await new Promise((resolve) => {
res.on('close', resolve);
});
}
});
});
```

## Why?

The problem is that the return value of Express middlewares is completely
ignored. This means that any middleware which is `async` will not be properly
awaited, and so calling `next()` in a middleware will return as soon as all
subsequent middlewares run their _synchronous_ code - but tracking the end of
any _asynchronous_ execution spawned from middlewares is impossible from the
point of a preceding middleware. The problem is illustrated by the following
snippet:

```typescript
import express from 'express';

const app = express();

app.use(async (req, res, next) => {
console.log('mw 1 start');
await next();
console.log('mw 1 end');
});

app.use(async (req, res, next) => {
console.log('mw 2 start');
await next();
console.log('mw 2 end');
});

app.use(async (req, res, next) => {
console.log('mw 3 start');
await new Promise((r) => setTimeout(r, 250));
res.end('hello world');
console.log('mw 3 end');
});

app.listen(8000);
```

The script will output the following sequence when a request is handled:

```
mw 1 start
mw 2 start
mw 3 start
mw 2 end
mw 1 end
mw 3 end
```

Notice the first and second middlewares log the end of their execution _before_
the last middleware finishes executing, even though each middleware awaits the
`next()` call. This means that `mw 1` has no direct way of telling when `mw 3`
(or any other middleware) finished handling the request. If we were to naively
use something like `container.fork(next)` in `mw 1`, the forked context would
only be available during the synchronous part of the subsequent middlewares,
because the `fork()` method awaits the provided callback and cleans up the
forked context when the callback resolves - but as we've seen, `next()` doesn't
return a Promise, so it will resolve immediately when all synchronous code has
been executed.

The snippet at the beginning of this recipe works by waiting for the response
stream to be closed before returning from the fork callback. Unless the app
crashes catastrophically, this will ensure that the forked DI context will stay
alive for the entire duration of the request handling pipeline.
2 changes: 2 additions & 0 deletions docs/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- [Intro to DICC](user/02-intro-to-dicc.md)
- [Services and dependencies](user/03-services-and-dependencies.md)
- [Config and compilation](user/04-config-and-compilation.md)
- Recipes
- [Express](recipes/01-express.md)
- Developer docs
- coming soon
- [GitHub](https://github.com/cdn77/dicc)
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"noImplicitThis": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"target": "ESNext",
"target": "ESNext"
}
}

0 comments on commit f04725a

Please sign in to comment.