From 6669fb26bbb575b7de8a268ad75aea4713476cc8 Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Mon, 7 Aug 2023 16:57:01 +0200 Subject: [PATCH] feat: experimental composition api via `useEvent()` ans async context support (#1546) --- docs/content/1.guide/6.utils.md | 60 +++++++++++++++++++++++++++++++-- package.json | 3 +- pnpm-lock.yaml | 28 ++++++++++++--- src/imports.ts | 1 + src/rollup/config.ts | 2 ++ src/runtime/app.ts | 10 ++++++ src/runtime/context.ts | 34 +++++++++++++++++++ src/runtime/index.ts | 1 + src/types/global.ts | 1 + src/types/nitro.ts | 4 +++ test/fixture/nitro.config.ts | 1 + test/fixture/routes/context.ts | 12 +++++++ test/tests.ts | 18 +++++++++- 13 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 src/runtime/context.ts create mode 100644 test/fixture/routes/context.ts diff --git a/docs/content/1.guide/6.utils.md b/docs/content/1.guide/6.utils.md index 17dcdb1686..8175faf205 100644 --- a/docs/content/1.guide/6.utils.md +++ b/docs/content/1.guide/6.utils.md @@ -9,9 +9,9 @@ Nitro helps you to stay organized allowing you to take advantage of the [`auto-i Every export in the `utils` directory and its subdirectories will become available globally in your application. -## Example +--- -Create a `utils/sum.ts` file where a function `useSum` is exported: +**Example:** Create a `utils/sum.ts` file where a function `useSum` is exported: ```ts [utils/sum.ts] export function useSum(a: number, b: number) { return a + b } @@ -25,3 +25,59 @@ export default defineEventHandler(() => { return { sum } }) ``` + +--- + +## Experimental Composition API + +Nitro (2.6+) enables a new server development experience in order to split application logic into smaller "composable" utilities that are fully decoupled from each other and can directly assess to a shared context (request event) without needing it to be passed along. This pattern is inspired from [Vue Composition API](https://vuejs.org/guide/extras/composition-api-faq.html#why-composition-api) and powered by [unjs/unctx](https://github.com/unjs/unctx). + +This feature is currently supported for Node.js and Bun runtimes and also coming soon to other presets that support [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage) interface. + +In order to enable composition API, you have to enable `asyncContext` flag: + +::code-group +```ts [nitro.config.ts] +export default defineNitroConfig({ + experimental: { + asyncContext: true + } +}); +``` +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + nitro: { + experimental: { + asyncContext: true + } + } +}) +``` +:: + +After enabling this flag, you can use `useEvent()` (auto imported) in any utility or composable to access the request event without manually passing it along: + +::code-group +```ts [with async context] +// routes/index.ts +export default defineEventHandler(async () => { + const user = await useAuth() +}) + +// utils/auth.ts +export function useAuth() { + return useSession(useEvent()) +} +``` +```ts [without async context] +// routes/index.ts +export default defineEventHandler(async (event) => { + const user = await useAuth(event) +}) + +// utils/auth.ts +export function useAuth(event) { + return useSession(event) +} +``` +:: diff --git a/package.json b/package.json index 7545341398..7b8172e228 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,8 @@ "std-env": "^3.3.3", "ufo": "^1.2.0", "uncrypto": "^0.1.3", - "unenv": "^1.6.1", + "unctx": "^2.3.1", + "unenv": "^1.6.2", "unimport": "^3.1.3", "unstorage": "^1.8.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 315a7075ff..62b3d1b2b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,9 +195,12 @@ importers: uncrypto: specifier: ^0.1.3 version: 0.1.3 + unctx: + specifier: ^2.3.1 + version: 2.3.1 unenv: - specifier: ^1.6.1 - version: 1.6.1 + specifier: ^1.6.2 + version: 1.6.2 unimport: specifier: ^3.1.3 version: 3.1.3(rollup@3.27.2) @@ -3082,6 +3085,12 @@ packages: /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.1 + dev: false + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3485,7 +3494,7 @@ packages: radix3: 1.0.1 ufo: 1.2.0 uncrypto: 0.1.3 - unenv: 1.6.1 + unenv: 1.6.2 dev: false /has-bigints@1.0.2: @@ -5550,14 +5559,23 @@ packages: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} dev: false + /unctx@2.3.1: + resolution: {integrity: sha512-PhKke8ZYauiqh3FEMVNm7ljvzQiph0Mt3GBRve03IJm7ukfaON2OBK795tLwhbyfzknuRRkW0+Ze+CQUmzOZ+A==} + dependencies: + acorn: 8.10.0 + estree-walker: 3.0.3 + magic-string: 0.30.2 + unplugin: 1.4.0 + dev: false + /undici@5.23.0: resolution: {integrity: sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==} engines: {node: '>=14.0'} dependencies: busboy: 1.6.0 - /unenv@1.6.1: - resolution: {integrity: sha512-cjQnvJctZluBwOCBtFT4ZRR1cCJOVrcDK/TXzdqc6I+ZKWBFVDs6JjH0qkK6d8RsFSRHbQkWRgSzu66e52FHBA==} + /unenv@1.6.2: + resolution: {integrity: sha512-oDbB3arlgPB8Berj4nD7z9ydzF8ITInlBunf0a+3YJkLDhI1DJrUdmv2ovPXAIXvxzW4FTIGtWqng6dk7/kziA==} dependencies: consola: 3.2.3 defu: 6.1.2 diff --git a/src/imports.ts b/src/imports.ts index de4dbd67c4..a89c459df1 100644 --- a/src/imports.ts +++ b/src/imports.ts @@ -16,6 +16,7 @@ export const nitroImports: Preset[] = [ "defineRenderHandler", "getRouteRules", "useAppConfig", + "useEvent", ], }, ]; diff --git a/src/rollup/config.ts b/src/rollup/config.ts index 6a4ee0f454..1d29022584 100644 --- a/src/rollup/config.ts +++ b/src/rollup/config.ts @@ -184,6 +184,8 @@ export const getRollupConfig = (nitro: Nitro): RollupConfig => { // @ts-expect-error "versions.nitro": version, "versions?.nitro": version, + // Internal + _asyncContext: nitro.options.experimental.asyncContext, }; // Universal import.meta diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 33f9b2d012..9348a48d5b 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -23,6 +23,7 @@ import { useRuntimeConfig } from "./config"; import { cachedEventHandler } from "./cache"; import { normalizeFetchResponse } from "./utils"; import { createRouteRulesHandler, getRouteRulesForPath } from "./route-rules"; +import { NitroAsyncContext, nitroAsyncContext } from "./context"; import type { $Fetch, NitroFetchRequest } from "nitropack"; import { plugins } from "#internal/nitro/virtual/plugins"; import errorHandler from "#internal/nitro/virtual/error-handler"; @@ -172,6 +173,15 @@ function createNitroApp(): NitroApp { h3App.use(config.app.baseURL as string, router.handler); + // Experimental async context support + if (import.meta._asyncContext) { + const _handler = h3App.handler; + h3App.handler = (event) => { + const ctx: NitroAsyncContext = { event }; + return nitroAsyncContext.callAsync(ctx, () => _handler(event)); + }; + } + const app: NitroApp = { hooks, h3App, diff --git a/src/runtime/context.ts b/src/runtime/context.ts new file mode 100644 index 0000000000..b4926395e0 --- /dev/null +++ b/src/runtime/context.ts @@ -0,0 +1,34 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { H3Event, createError } from "h3"; +import { getContext } from "unctx"; + +export interface NitroAsyncContext { + event: H3Event; +} + +export const nitroAsyncContext = getContext("nitro-app", { + asyncContext: import.meta._asyncContext, + AsyncLocalStorage: import.meta._asyncContext ? AsyncLocalStorage : undefined, +}); + +/** + * + * Access to the current Nitro request event. + * + * @experimental + * - Requires `experimental.asyncContext: true` config to work. + * - Works in Node.js and limited runtimes only + * + */ +export function useEvent(): H3Event { + try { + return nitroAsyncContext.use().event; + } catch { + const hint = import.meta._asyncContext + ? "Note: This is an experimental feature and might be broken on non-Node.js environments." + : "Enable the experimental flag using `experimental.asyncContext: true`."; + throw createError({ + message: `Nitro request context is not available. ${hint}`, + }); + } +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 269224370c..ff2d3c3e47 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -5,3 +5,4 @@ export * from "./plugin"; export * from "./renderer"; export { getRouteRules } from "./route-rules"; export { useStorage } from "./storage"; +export { useEvent } from "./context"; diff --git a/src/types/global.ts b/src/types/global.ts index d4f69f64e5..f245b70aa1 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -1,6 +1,7 @@ import type { NitroOptions } from "./nitro"; export interface NitroStaticBuildFlags { + _asyncContext?: boolean; dev?: boolean; client?: boolean; nitro?: boolean; diff --git a/src/types/nitro.ts b/src/types/nitro.ts index b1633d90b8..f4e97d7ed6 100644 --- a/src/types/nitro.ts +++ b/src/types/nitro.ts @@ -227,6 +227,10 @@ export interface NitroOptions extends PresetOptions { * See https://github.com/microsoft/TypeScript/pull/51669 */ typescriptBundlerResolution?: boolean; + /** + * Enable native async context support for useEvent() + */ + asyncContext?: boolean; }; future: { nativeSWR: boolean; diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index ebf3671861..fb26a3a3bb 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -82,5 +82,6 @@ export default defineNitroConfig({ }, experimental: { openAPI: true, + asyncContext: true, }, }); diff --git a/test/fixture/routes/context.ts b/test/fixture/routes/context.ts new file mode 100644 index 0000000000..c51deaa0dd --- /dev/null +++ b/test/fixture/routes/context.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async () => { + await Promise.resolve(setTimeout(() => {}, 10)); + return await useTest(); +}); + +function useTest() { + return { + context: { + path: useEvent().path, + }, + }; +} diff --git a/test/tests.ts b/test/tests.ts index d24532b9cb..e60ec030b5 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -484,7 +484,12 @@ export function testNitro( // TODO: Node presets do not split cookies // https://github.com/unjs/nitro/issues/1462 // (vercel and deno-server uses node only for tests only) - const notSplitingPresets = ["node", "nitro-dev", "vercel", nodeVersion < 18 && "deno-server"].filter(Boolean); + const notSplitingPresets = [ + "node", + "nitro-dev", + "vercel", + nodeVersion < 18 && "deno-server", + ].filter(Boolean); if (notSplitingPresets.includes(ctx.preset)) { expectedCookies = nodeVersion < 18 @@ -523,4 +528,15 @@ export function testNitro( expect(allErrorMessages).to.includes("Service Unavailable"); }); }); + + describe("async context", () => { + it.skipIf(!ctx.nitro.options.node)("works", async () => { + const { data } = await callHandler({ url: "/context?foo" }); + expect(data).toMatchObject({ + context: { + path: "/context?foo", + }, + }); + }); + }); }