diff --git a/src/index.ts b/src/index.ts index 2acceb20b..f60bcaaf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4042,6 +4042,10 @@ export default class Elysia< } = schemaOrRun const localHook = hooks as AnyLocalHook + // Apply macros to expand group options before merging + // This ensures macro-defined hooks run before nested plugin hooks + this.applyMacro(hook) + const hasStandaloneSchema = body || headers || query || params || cookie || response @@ -4062,20 +4066,29 @@ export default class Elysia< localHook.error, ...(sandbox.event.error ?? []) ], - standaloneValidator: !hasStandaloneSchema - ? localHook.standaloneValidator - : [ - ...(localHook.standaloneValidator ?? - []), - { - body, - headers, - query, - params, - cookie, - response - } - ] + // Merge macro's standaloneValidator with local and group schema + standaloneValidator: + hook.standaloneValidator || + localHook.standaloneValidator || + hasStandaloneSchema + ? [ + ...(hook.standaloneValidator ?? []), + ...(localHook.standaloneValidator ?? + []), + ...(hasStandaloneSchema + ? [ + { + body, + headers, + query, + params, + cookie, + response + } + ] + : []) + ] + : undefined }), undefined ) diff --git a/test/core/macro-lifecycle.test.ts b/test/core/macro-lifecycle.test.ts new file mode 100644 index 000000000..e3548f371 --- /dev/null +++ b/test/core/macro-lifecycle.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia } from '../../src' + +describe('macro beforeHandle lifecycle order', () => { + it('should run macro beforeHandle before nested plugin resolve', async () => { + const executionOrder: string[] = [] + + // Auth service with resolve + macro + const authService = new Elysia({ name: 'auth-service' }) + .resolve(() => { + executionOrder.push('authService.resolve') + return { userId: undefined } // Simulating no auth + }) + .macro({ + isSignedIn: { + beforeHandle({ userId }) { + executionOrder.push('isSignedIn.beforeHandle') + if (!userId) throw new Error('Unauthorized') + } + } + }) + .as('scoped') + + // DB client that requires userId + const dbClient = new Elysia({ name: 'db-client' }) + .resolve((ctx) => { + executionOrder.push('dbClient.resolve') + const userId = (ctx as { userId?: string }).userId + if (!userId) throw new Error('User ID is required') + return { db: { userId } } + }) + .as('scoped') + + // Feature module using dbClient + const feature = new Elysia({ name: 'feature' }) + .use(dbClient) + .get('/', ({ db }) => `Hello ${db.userId}`) + + // Main app + const app = new Elysia() + .use(authService) + .group('/v1', { isSignedIn: true }, (app) => app.use(feature)) + + const response = await app.handle(new Request('http://localhost/v1/')) + + // The macro's beforeHandle should run BEFORE dbClient's resolve + // So we should get "Unauthorized" error, not "User ID is required" + const body = await response.text() + + console.log('Execution order:', executionOrder) + console.log('Response:', body) + + // Expected order: authService.resolve -> isSignedIn.beforeHandle (throws) + // dbClient.resolve should NOT run because beforeHandle throws first + expect(executionOrder).toContain('authService.resolve') + expect(executionOrder).toContain('isSignedIn.beforeHandle') + expect(executionOrder).not.toContain('dbClient.resolve') + expect(body).toContain('Unauthorized') + }) + + it('should run hooks in registration order within same queue', async () => { + const executionOrder: string[] = [] + + const app = new Elysia() + .resolve(() => { + executionOrder.push('resolve1') + return { val1: 1 } + }) + .onBeforeHandle(() => { + executionOrder.push('beforeHandle1') + }) + .resolve(() => { + executionOrder.push('resolve2') + return { val2: 2 } + }) + .onBeforeHandle(() => { + executionOrder.push('beforeHandle2') + }) + .get('/', () => 'ok') + + await app.handle(new Request('http://localhost/')) + + console.log('Execution order:', executionOrder) + + // According to docs, resolve and beforeHandle share the same queue + // Order should be: resolve1 -> beforeHandle1 -> resolve2 -> beforeHandle2 + expect(executionOrder).toEqual([ + 'resolve1', + 'beforeHandle1', + 'resolve2', + 'beforeHandle2' + ]) + }) + + it('should demonstrate the issue with macro in group', async () => { + const executionOrder: string[] = [] + let errorMessage = '' + + const authPlugin = new Elysia({ name: 'auth' }) + .resolve(() => { + executionOrder.push('auth.resolve') + return { userId: 'user123' } // Auth succeeds + }) + .macro({ + requireAuth: { + beforeHandle({ userId }) { + executionOrder.push('requireAuth.beforeHandle') + if (!userId) throw new Error('Unauthorized') + } + } + }) + .as('scoped') + + const dataPlugin = new Elysia({ name: 'data' }) + .resolve((ctx) => { + executionOrder.push('data.resolve') + return { data: 'some data' } + }) + .as('scoped') + + const app = new Elysia() + .use(authPlugin) + .onError(({ error }) => { + errorMessage = error.message + return error.message + }) + .group('/api', { requireAuth: true }, (app) => app.use(dataPlugin).get('/', ({ data }) => data)) + + const response = await app.handle(new Request('http://localhost/api/')) + + console.log('Execution order:', executionOrder) + console.log('Error:', errorMessage) + + // With auth succeeding (userId = 'user123'), all should run + // Expected: auth.resolve -> requireAuth.beforeHandle -> data.resolve + expect(executionOrder).toEqual([ + 'auth.resolve', + 'requireAuth.beforeHandle', + 'data.resolve' + ]) + }) + + it('should preserve macro schema when merging with nested plugin hooks', async () => { + const { t } = await import('../../src') + + // Macro that adds both beforeHandle and body schema + const validatedMacro = new Elysia({ name: 'validated-macro' }) + .macro({ + validatePayload: { + body: t.Object({ + name: t.String() + }), + beforeHandle() { + // Macro's beforeHandle + } + } + }) + .as('scoped') + + const nestedPlugin = new Elysia({ name: 'nested' }) + .resolve(() => ({ nested: true })) + .as('scoped') + + const app = new Elysia() + .use(validatedMacro) + .group('/api', { validatePayload: true }, (app) => + app.use(nestedPlugin).post('/', ({ body }) => body.name) + ) + + // Valid request - should pass validation + const validResponse = await app.handle( + new Request('http://localhost/api/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'test' }) + }) + ) + expect(validResponse.status).toBe(200) + expect(await validResponse.text()).toBe('test') + + // Invalid request - should fail validation (macro schema should be preserved) + const invalidResponse = await app.handle( + new Request('http://localhost/api/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invalid: 'data' }) + }) + ) + // If macro schema is lost, this would be 200 instead of 422 + expect(invalidResponse.status).toBe(422) + }) +})