diff --git a/adonis-typings/context.ts b/adonis-typings/context.ts index ccda3ad..aa866ae 100644 --- a/adonis-typings/context.ts +++ b/adonis-typings/context.ts @@ -45,6 +45,21 @@ declare module '@ioc:Adonis/Core/HttpContext' { extends MacroableConstructorContract { app?: ApplicationContract + /** + * Whether async hooks are enabled and the async HTTP context can be used. + */ + readonly asyncHttpContextEnabled: boolean + + /** + * Returns the current HTTP context or null if there is none. + */ + get(): HttpContextContract | null + + /** + * Returns the current HTTP context or throws if there is none. + */ + getOrFail(): HttpContextContract + /** * Creates a new fake context instance for a given route. */ diff --git a/adonis-typings/request.ts b/adonis-typings/request.ts index 1055bc2..d536a47 100644 --- a/adonis-typings/request.ts +++ b/adonis-typings/request.ts @@ -593,6 +593,7 @@ declare module '@ioc:Adonis/Core/Request' { allowMethodSpoofing: boolean getIp?: (request: RequestContract) => string trustProxy: (address: string, distance: number) => boolean + enableAsyncHttpContext?: boolean } /** diff --git a/package.json b/package.json index f153cbc..f65398f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node japaFile.js", + "test": "node japaFile.js && ASYNC_HOOKS=1 node japaFile.js", "clean": "del build", "compile": "npm run lint && npm run clean && tsc", "build": "npm run compile", diff --git a/src/AsyncHttpContext/index.ts b/src/AsyncHttpContext/index.ts new file mode 100644 index 0000000..074df47 --- /dev/null +++ b/src/AsyncHttpContext/index.ts @@ -0,0 +1,33 @@ +/** + * @adonisjs/http-server + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// + +import { AsyncLocalStorage } from 'async_hooks' +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export let asyncHttpContextEnabled = false + +export function setAsyncHttpContextEnabled(enabled: boolean) { + asyncHttpContextEnabled = enabled +} + +export const adonisLocalStorage = new AsyncLocalStorage() + +export class AsyncHttpContext { + constructor(private ctx: HttpContextContract) {} + + public getContext() { + return this.ctx + } + + public run(callback: () => any) { + return adonisLocalStorage.run(this, callback) + } +} diff --git a/src/HttpContext/index.ts b/src/HttpContext/index.ts index 96e400e..f9d046e 100644 --- a/src/HttpContext/index.ts +++ b/src/HttpContext/index.ts @@ -24,6 +24,8 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import { Request } from '../Request' import { Response } from '../Response' import { processPattern } from '../helpers' +import { adonisLocalStorage, asyncHttpContextEnabled } from '../AsyncHttpContext' +import { Exception } from '@poppinss/utils' /** * Http context is passed to all route handlers, middleware, @@ -35,6 +37,27 @@ export class HttpContext extends Macroable implements HttpContextContract { */ public static app: ApplicationContract + public static get asyncHttpContextEnabled() { + return asyncHttpContextEnabled + } + + public static get(): HttpContextContract | null { + const store = adonisLocalStorage.getStore() + return store !== undefined ? store.getContext() : null + } + + public static getOrFail() { + const store = adonisLocalStorage.getStore() + if (store !== undefined) { + return store.getContext() + } + if (asyncHttpContextEnabled) { + throw new Exception('async HTTP context accessed outside of a request context') + } else { + throw new Exception('async HTTP context is disabled') + } + } + /** * A unique key for the current route */ diff --git a/src/Server/index.ts b/src/Server/index.ts index 0600be4..97d3085 100644 --- a/src/Server/index.ts +++ b/src/Server/index.ts @@ -27,6 +27,11 @@ import { HttpContext } from '../HttpContext' import { RequestHandler } from './RequestHandler' import { MiddlewareStore } from '../MiddlewareStore' import { ExceptionManager } from './ExceptionManager' +import { + asyncHttpContextEnabled, + AsyncHttpContext, + setAsyncHttpContextEnabled, +} from '../AsyncHttpContext' /** * Server class handles the HTTP requests by using all Adonis micro modules. @@ -80,6 +85,8 @@ export class Server implements ServerContract { if (httpConfig.cookie.maxAge && typeof httpConfig.cookie.maxAge === 'string') { httpConfig.cookie.maxAge = ms(httpConfig.cookie.maxAge) / 1000 } + + setAsyncHttpContextEnabled(httpConfig.enableAsyncHttpContext || false) } /** @@ -123,6 +130,13 @@ export class Server implements ServerContract { ) } + /** + * Returns a new async HTTP context for the new request + */ + private getAsyncContext(ctx: HttpContextContract): AsyncHttpContext { + return new AsyncHttpContext(ctx) + } + /** * Define custom error handler to handler all errors * occurred during HTTP request @@ -161,6 +175,19 @@ export class Server implements ServerContract { const requestAction = this.getProfilerRow(request) const ctx = this.getContext(request, response, requestAction) + if (asyncHttpContextEnabled) { + const asyncContext = this.getAsyncContext(ctx) + return asyncContext.run(() => this.handleImpl(ctx, requestAction, res)) + } else { + this.handleImpl(ctx, requestAction, res) + } + } + + private async handleImpl( + ctx: HttpContext, + requestAction: ProfilerRowContract, + res: ServerResponse + ) { /* * Handle request by executing hooks, request middleware stack * and route handler diff --git a/test-helpers/index.ts b/test-helpers/index.ts index 225f1f3..83dd16d 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -30,6 +30,7 @@ export const requestConfig: RequestConfig = { trustProxy: proxyaddr.compile('loopback'), subdomainOffset: 2, generateRequestId: true, + enableAsyncHttpContext: Boolean(process.env.ASYNC_HOOKS), } export const responseConfig: ResponseConfig = { @@ -59,7 +60,8 @@ export async function setupApp(providers?: string[]) { export const appKey = '${appSecret}' export const http = { trustProxy: () => true, - cookie: {} + cookie: {}, + enableAsyncHttpContext: ${process.env.ASYNC_HOOKS ? 'true' : 'false'} } ` ) diff --git a/test/server.spec.ts b/test/server.spec.ts index f4f99dd..fa371ac 100644 --- a/test/server.spec.ts +++ b/test/server.spec.ts @@ -19,6 +19,7 @@ import { ProfilerAction, ProfilerRow } from '@ioc:Adonis/Core/Profiler' import { Server } from '../src/Server' import { serverConfig, fs, setupApp, encryption } from '../test-helpers' +import { HttpContext } from '../src/HttpContext' test.group('Server | Response handling', (group) => { group.afterEach(async () => { @@ -1257,4 +1258,74 @@ test.group('Server | all', (group) => { const { body } = await supertest(httpServer).get(url).expect(200) assert.deepEqual(body, { hasValidSignature: true }) }) + + if (process.env.ASYNC_HOOKS) { + test('async HTTP context (enabled)', async (assert) => { + const app = await setupApp() + const server = new Server(app, encryption, serverConfig) + + server.router.get('/', async (ctx) => { + return { + enabled: HttpContext.asyncHttpContextEnabled, + get: HttpContext.get() === ctx, + getOrFail: HttpContext.getOrFail() === ctx, + } + }) + + server.optimize() + + const httpServer = createServer(server.handle.bind(server)) + + assert.strictEqual(HttpContext.asyncHttpContextEnabled, true) + assert.strictEqual(HttpContext.get(), null) + assert.throws( + () => HttpContext.getOrFail(), + 'async HTTP context accessed outside of a request context' + ) + + const { body } = await supertest(httpServer).get('/').expect(200) + assert.deepStrictEqual(body, { + enabled: true, + get: true, + getOrFail: true, + }) + }) + } else { + test('async HTTP context (disabled)', async (assert) => { + const app = await setupApp() + const server = new Server(app, encryption, serverConfig) + + server.errorHandler(async (error, { response }) => { + response.status(200).send(error.message) + }) + + server.router.get('/', async () => { + return { + enabled: HttpContext.asyncHttpContextEnabled, + get: HttpContext.get() === null, + } + }) + + server.router.get('/fail', async () => { + return HttpContext.getOrFail() + }) + + server.optimize() + + const httpServer = createServer(server.handle.bind(server)) + + assert.strictEqual(HttpContext.asyncHttpContextEnabled, false) + assert.strictEqual(HttpContext.get(), null) + assert.throws(() => HttpContext.getOrFail(), 'async HTTP context is disabled') + + const { body } = await supertest(httpServer).get('/').expect(200) + assert.deepStrictEqual(body, { + enabled: false, + get: true, + }) + + const { text } = await supertest(httpServer).get('/fail').expect(200) + assert.strictEqual(text, 'async HTTP context is disabled') + }) + } })