Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add async HTTP context #18

Merged
merged 1 commit into from May 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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')
})
}
})