Skip to content

Commit

Permalink
feat: add async HTTP context (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
targos committed May 28, 2021
1 parent 3ac1567 commit a7e6d0d
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 2 deletions.
15 changes: 15 additions & 0 deletions adonis-typings/context.ts
Expand Up @@ -45,6 +45,21 @@ declare module '@ioc:Adonis/Core/HttpContext' {
extends MacroableConstructorContract<HttpContextContract> {
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.
*/
Expand Down
1 change: 1 addition & 0 deletions adonis-typings/request.ts
Expand Up @@ -593,6 +593,7 @@ declare module '@ioc:Adonis/Core/Request' {
allowMethodSpoofing: boolean
getIp?: (request: RequestContract) => string
trustProxy: (address: string, distance: number) => boolean
enableAsyncHttpContext?: boolean
}

/**
Expand Down
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -14,7 +14,7 @@
"scripts": {
"mrm": "mrm --preset=@adonisjs/mrm-preset",
"pretest": "npm run lint",
"test": "node japaFile.js",
"test": "node japaFile.js && cross-env ASYNC_HOOKS=1 node japaFile.js",
"clean": "del build",
"compile": "npm run lint && npm run clean && tsc",
"build": "npm run compile",
Expand Down Expand Up @@ -50,6 +50,7 @@
"@types/supertest": "^2.0.11",
"autocannon": "^7.3.0",
"commitizen": "^4.2.3",
"cross-env": "^7.0.3",
"cz-conventional-changelog": "^3.3.0",
"del-cli": "^3.0.1",
"eslint": "^7.26.0",
Expand Down
33 changes: 33 additions & 0 deletions src/AsyncHttpContext/index.ts
@@ -0,0 +1,33 @@
/**
* @adonisjs/http-server
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/// <reference path="../../adonis-typings/index.ts" />

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<AsyncHttpContext>()

export class AsyncHttpContext {
constructor(private ctx: HttpContextContract) {}

public getContext() {
return this.ctx
}

public run(callback: () => any) {
return adonisLocalStorage.run(this, callback)
}
}
23 changes: 23 additions & 0 deletions src/HttpContext/index.ts
Expand Up @@ -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,
Expand All @@ -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
*/
Expand Down
27 changes: 27 additions & 0 deletions src/Server/index.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion test-helpers/index.ts
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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'}
}
`
)
Expand Down
71 changes: 71 additions & 0 deletions test/server.spec.ts
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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')
})
}
})

0 comments on commit a7e6d0d

Please sign in to comment.