Skip to content

Commit a7e6d0d

Browse files
authored
feat: add async HTTP context (#18)
1 parent 3ac1567 commit a7e6d0d

File tree

8 files changed

+175
-2
lines changed

8 files changed

+175
-2
lines changed

adonis-typings/context.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ declare module '@ioc:Adonis/Core/HttpContext' {
4545
extends MacroableConstructorContract<HttpContextContract> {
4646
app?: ApplicationContract
4747

48+
/**
49+
* Whether async hooks are enabled and the async HTTP context can be used.
50+
*/
51+
readonly asyncHttpContextEnabled: boolean
52+
53+
/**
54+
* Returns the current HTTP context or null if there is none.
55+
*/
56+
get(): HttpContextContract | null
57+
58+
/**
59+
* Returns the current HTTP context or throws if there is none.
60+
*/
61+
getOrFail(): HttpContextContract
62+
4863
/**
4964
* Creates a new fake context instance for a given route.
5065
*/

adonis-typings/request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ declare module '@ioc:Adonis/Core/Request' {
593593
allowMethodSpoofing: boolean
594594
getIp?: (request: RequestContract) => string
595595
trustProxy: (address: string, distance: number) => boolean
596+
enableAsyncHttpContext?: boolean
596597
}
597598

598599
/**

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"scripts": {
1515
"mrm": "mrm --preset=@adonisjs/mrm-preset",
1616
"pretest": "npm run lint",
17-
"test": "node japaFile.js",
17+
"test": "node japaFile.js && cross-env ASYNC_HOOKS=1 node japaFile.js",
1818
"clean": "del build",
1919
"compile": "npm run lint && npm run clean && tsc",
2020
"build": "npm run compile",
@@ -50,6 +50,7 @@
5050
"@types/supertest": "^2.0.11",
5151
"autocannon": "^7.3.0",
5252
"commitizen": "^4.2.3",
53+
"cross-env": "^7.0.3",
5354
"cz-conventional-changelog": "^3.3.0",
5455
"del-cli": "^3.0.1",
5556
"eslint": "^7.26.0",

src/AsyncHttpContext/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @adonisjs/http-server
3+
*
4+
* (c) Harminder Virk <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
/// <reference path="../../adonis-typings/index.ts" />
11+
12+
import { AsyncLocalStorage } from 'async_hooks'
13+
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
14+
15+
export let asyncHttpContextEnabled = false
16+
17+
export function setAsyncHttpContextEnabled(enabled: boolean) {
18+
asyncHttpContextEnabled = enabled
19+
}
20+
21+
export const adonisLocalStorage = new AsyncLocalStorage<AsyncHttpContext>()
22+
23+
export class AsyncHttpContext {
24+
constructor(private ctx: HttpContextContract) {}
25+
26+
public getContext() {
27+
return this.ctx
28+
}
29+
30+
public run(callback: () => any) {
31+
return adonisLocalStorage.run(this, callback)
32+
}
33+
}

src/HttpContext/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
2424
import { Request } from '../Request'
2525
import { Response } from '../Response'
2626
import { processPattern } from '../helpers'
27+
import { adonisLocalStorage, asyncHttpContextEnabled } from '../AsyncHttpContext'
28+
import { Exception } from '@poppinss/utils'
2729

2830
/**
2931
* Http context is passed to all route handlers, middleware,
@@ -35,6 +37,27 @@ export class HttpContext extends Macroable implements HttpContextContract {
3537
*/
3638
public static app: ApplicationContract
3739

40+
public static get asyncHttpContextEnabled() {
41+
return asyncHttpContextEnabled
42+
}
43+
44+
public static get(): HttpContextContract | null {
45+
const store = adonisLocalStorage.getStore()
46+
return store !== undefined ? store.getContext() : null
47+
}
48+
49+
public static getOrFail() {
50+
const store = adonisLocalStorage.getStore()
51+
if (store !== undefined) {
52+
return store.getContext()
53+
}
54+
if (asyncHttpContextEnabled) {
55+
throw new Exception('async HTTP context accessed outside of a request context')
56+
} else {
57+
throw new Exception('async HTTP context is disabled')
58+
}
59+
}
60+
3861
/**
3962
* A unique key for the current route
4063
*/

src/Server/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import { HttpContext } from '../HttpContext'
2727
import { RequestHandler } from './RequestHandler'
2828
import { MiddlewareStore } from '../MiddlewareStore'
2929
import { ExceptionManager } from './ExceptionManager'
30+
import {
31+
asyncHttpContextEnabled,
32+
AsyncHttpContext,
33+
setAsyncHttpContextEnabled,
34+
} from '../AsyncHttpContext'
3035

3136
/**
3237
* Server class handles the HTTP requests by using all Adonis micro modules.
@@ -80,6 +85,8 @@ export class Server implements ServerContract {
8085
if (httpConfig.cookie.maxAge && typeof httpConfig.cookie.maxAge === 'string') {
8186
httpConfig.cookie.maxAge = ms(httpConfig.cookie.maxAge) / 1000
8287
}
88+
89+
setAsyncHttpContextEnabled(httpConfig.enableAsyncHttpContext || false)
8390
}
8491

8592
/**
@@ -123,6 +130,13 @@ export class Server implements ServerContract {
123130
)
124131
}
125132

133+
/**
134+
* Returns a new async HTTP context for the new request
135+
*/
136+
private getAsyncContext(ctx: HttpContextContract): AsyncHttpContext {
137+
return new AsyncHttpContext(ctx)
138+
}
139+
126140
/**
127141
* Define custom error handler to handler all errors
128142
* occurred during HTTP request
@@ -161,6 +175,19 @@ export class Server implements ServerContract {
161175
const requestAction = this.getProfilerRow(request)
162176
const ctx = this.getContext(request, response, requestAction)
163177

178+
if (asyncHttpContextEnabled) {
179+
const asyncContext = this.getAsyncContext(ctx)
180+
return asyncContext.run(() => this.handleImpl(ctx, requestAction, res))
181+
} else {
182+
this.handleImpl(ctx, requestAction, res)
183+
}
184+
}
185+
186+
private async handleImpl(
187+
ctx: HttpContext,
188+
requestAction: ProfilerRowContract,
189+
res: ServerResponse
190+
) {
164191
/*
165192
* Handle request by executing hooks, request middleware stack
166193
* and route handler

test-helpers/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const requestConfig: RequestConfig = {
3030
trustProxy: proxyaddr.compile('loopback'),
3131
subdomainOffset: 2,
3232
generateRequestId: true,
33+
enableAsyncHttpContext: Boolean(process.env.ASYNC_HOOKS),
3334
}
3435

3536
export const responseConfig: ResponseConfig = {
@@ -59,7 +60,8 @@ export async function setupApp(providers?: string[]) {
5960
export const appKey = '${appSecret}'
6061
export const http = {
6162
trustProxy: () => true,
62-
cookie: {}
63+
cookie: {},
64+
enableAsyncHttpContext: ${process.env.ASYNC_HOOKS ? 'true' : 'false'}
6365
}
6466
`
6567
)

test/server.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ProfilerAction, ProfilerRow } from '@ioc:Adonis/Core/Profiler'
1919

2020
import { Server } from '../src/Server'
2121
import { serverConfig, fs, setupApp, encryption } from '../test-helpers'
22+
import { HttpContext } from '../src/HttpContext'
2223

2324
test.group('Server | Response handling', (group) => {
2425
group.afterEach(async () => {
@@ -1257,4 +1258,74 @@ test.group('Server | all', (group) => {
12571258
const { body } = await supertest(httpServer).get(url).expect(200)
12581259
assert.deepEqual(body, { hasValidSignature: true })
12591260
})
1261+
1262+
if (process.env.ASYNC_HOOKS) {
1263+
test('async HTTP context (enabled)', async (assert) => {
1264+
const app = await setupApp()
1265+
const server = new Server(app, encryption, serverConfig)
1266+
1267+
server.router.get('/', async (ctx) => {
1268+
return {
1269+
enabled: HttpContext.asyncHttpContextEnabled,
1270+
get: HttpContext.get() === ctx,
1271+
getOrFail: HttpContext.getOrFail() === ctx,
1272+
}
1273+
})
1274+
1275+
server.optimize()
1276+
1277+
const httpServer = createServer(server.handle.bind(server))
1278+
1279+
assert.strictEqual(HttpContext.asyncHttpContextEnabled, true)
1280+
assert.strictEqual(HttpContext.get(), null)
1281+
assert.throws(
1282+
() => HttpContext.getOrFail(),
1283+
'async HTTP context accessed outside of a request context'
1284+
)
1285+
1286+
const { body } = await supertest(httpServer).get('/').expect(200)
1287+
assert.deepStrictEqual(body, {
1288+
enabled: true,
1289+
get: true,
1290+
getOrFail: true,
1291+
})
1292+
})
1293+
} else {
1294+
test('async HTTP context (disabled)', async (assert) => {
1295+
const app = await setupApp()
1296+
const server = new Server(app, encryption, serverConfig)
1297+
1298+
server.errorHandler(async (error, { response }) => {
1299+
response.status(200).send(error.message)
1300+
})
1301+
1302+
server.router.get('/', async () => {
1303+
return {
1304+
enabled: HttpContext.asyncHttpContextEnabled,
1305+
get: HttpContext.get() === null,
1306+
}
1307+
})
1308+
1309+
server.router.get('/fail', async () => {
1310+
return HttpContext.getOrFail()
1311+
})
1312+
1313+
server.optimize()
1314+
1315+
const httpServer = createServer(server.handle.bind(server))
1316+
1317+
assert.strictEqual(HttpContext.asyncHttpContextEnabled, false)
1318+
assert.strictEqual(HttpContext.get(), null)
1319+
assert.throws(() => HttpContext.getOrFail(), 'async HTTP context is disabled')
1320+
1321+
const { body } = await supertest(httpServer).get('/').expect(200)
1322+
assert.deepStrictEqual(body, {
1323+
enabled: false,
1324+
get: true,
1325+
})
1326+
1327+
const { text } = await supertest(httpServer).get('/fail').expect(200)
1328+
assert.strictEqual(text, 'async HTTP context is disabled')
1329+
})
1330+
}
12601331
})

0 commit comments

Comments
 (0)