diff --git a/src/index.ts b/src/index.ts index 23475f1f1..cdf7e166a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,6 +144,7 @@ import type { DocumentDecoration, AfterHandler, NonResolvableMacroKey, + DiscriminatedMacroEntry, StandardSchemaV1Like, ElysiaHandlerToResponseSchema, ElysiaHandlerToResponseSchemas, @@ -5303,8 +5304,7 @@ export default class Elysia< resolve: Partial< Ephemeral['resolve'] & Volatile['resolve'] > & - // @ts-ignore - MacroContext['resolve'] + (MacroContext & { resolve: {} })['resolve'] }, Definitions['error'] > @@ -5335,9 +5335,77 @@ export default class Elysia< Volatile > + // Discriminated overload: precise macro context based on activated flags + // Only matches when prior macros exist (non-empty macroFn) + macro< + const Entry extends Record< + string, + DiscriminatedMacroEntry< + Metadata['macroFn'], + IntersectIfObjectSchema< + MergeSchema< + UnwrapRoute< + Metadata['macro'] & + InputSchema< + keyof Definitions['typebox'] & string + >, + Definitions['typebox'], + BasePath + >, + MergeSchema< + Volatile['schema'], + MergeSchema< + Ephemeral['schema'], + Metadata['schema'] + > + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, + Singleton & { + derive: Partial< + Ephemeral['derive'] & Volatile['derive'] + > + resolve: Partial< + Ephemeral['resolve'] & Volatile['resolve'] + > + }, + Definitions['error'], + Definitions['typebox'] + > + >, + const NewMacro extends Entry + >( + macro: Entry + ): Elysia< + BasePath, + Singleton, + Definitions, + { + schema: Metadata['schema'] + standaloneSchema: Metadata['standaloneSchema'] + macro: Metadata['macro'] & Partial> + macroFn: Metadata['macroFn'] & NewMacro + parser: Metadata['parser'] + response: Metadata['response'] + }, + Routes, + Ephemeral, + Volatile + > + macro< const Input extends Metadata['macro'] & InputSchema, + const MacroContext extends {} extends Metadata['macroFn'] + ? {} + : MacroToContext< + Metadata['macroFn'], + Record, + Definitions['typebox'] + >, const NewMacro extends Macro< Metadata['macro'] & InputSchema, @@ -5353,10 +5421,12 @@ export default class Elysia< Metadata['standaloneSchema'] & Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] - >, + > & + MacroContext, Singleton & { derive: Partial - resolve: Partial + resolve: Partial & + (MacroContext & { resolve: {} })['resolve'] }, Definitions['error'] > @@ -5382,6 +5452,13 @@ export default class Elysia< macro< const Input extends Metadata['macro'] & InputSchema, + const MacroContext extends {} extends Metadata['macroFn'] + ? {} + : MacroToContext< + Metadata['macroFn'], + Record, + Definitions['typebox'] + >, const NewMacro extends MaybeFunction< Macro< Input, @@ -5397,10 +5474,12 @@ export default class Elysia< Metadata['standaloneSchema'] & Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] - >, + > & + MacroContext, Singleton & { derive: Partial - resolve: Partial + resolve: Partial & + (MacroContext & { resolve: {} })['resolve'] }, Definitions['error'] > @@ -5469,9 +5548,29 @@ export default class Elysia< applied[seed] = true - for (let [k, value] of Object.entries(macroHook)) { + const entries = Object.entries(macroHook) + + for (const [k, value] of entries) { if (k === 'seed') continue + if (k in macro) { + this.applyMacro( + localHook, + { [k]: value }, + { applied, iteration: iteration + 1 } + ) + + // Remove the raw macro flag key (e.g. `auth`) from localHook now + // that its dependency has been recursively expanded. This prevents + // the flag from leaking as an unrecognised property into the final hook. + delete localHook[key] + } + } + + for (let [k, value] of entries) { + if (k === 'seed') continue + if (k in macro) continue + if (k in emptySchema) { insertStandaloneValidator( localHook, @@ -5499,17 +5598,6 @@ export default class Elysia< continue } - if (k in macro) { - this.applyMacro( - localHook, - { [k]: value }, - { applied, iteration: iteration + 1 } - ) - - delete localHook[key] - continue - } - if ( (k === 'derive' || k === 'resolve') && typeof value === 'function' diff --git a/src/types.ts b/src/types.ts index 440f9e0a7..06da66569 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1789,6 +1789,90 @@ export type NonResolvableMacroKey = | keyof InputSchema | 'resolve' +/** + * Build a discriminated union of macro entry shapes. + * Each union member correlates specific flag values (e.g., `sessions: true`) + * with the corresponding callback context. TypeScript's union narrowing + * picks the correct member based on actual flag values in the object literal. + * + * Generates: no-flags entry | single-flag entries | all-flags entry + */ +export type DiscriminatedMacroEntry< + MacroFn extends Macro, + TypedRoute extends RouteSchema = {}, + Singleton extends SingletonBase = { + decorator: {} + store: {} + derive: {} + resolve: {} + }, + Errors extends Record = {}, + Definitions extends DefinitionBase['typebox'] = {} +> = {} extends MacroFn + ? MacroProperty<{}, TypedRoute, Singleton, Errors> & + Record & { call?: never; bind?: never } + : + | MacroEntryForSelected + | SingleKeyMacroEntries + | MacroEntryForSelected< + MacroFn, + keyof MacroFn & string, + TypedRoute, + Singleton, + Errors, + Definitions + > + +type MacroEntryForSelected< + MacroFn extends Macro, + Selected extends keyof MacroFn, + TypedRoute extends RouteSchema = {}, + Singleton extends SingletonBase = { + decorator: {} + store: {} + derive: {} + resolve: {} + }, + Errors extends Record = {}, + Definitions extends DefinitionBase['typebox'] = {} +> = { + [K in Selected]: true +} & { + [K in Exclude]?: false +} & { call?: never; bind?: never } & MacroProperty< + {}, + TypedRoute & + MacroToContext< + MacroFn, + { [K in Selected]: true }, + Definitions + >, + Singleton & { + resolve: (MacroToContext< + MacroFn, + { [K in Selected]: true }, + Definitions + > & { resolve: {} })['resolve'] + }, + Errors +> + +type SingleKeyMacroEntries< + MacroFn extends Macro, + TypedRoute extends RouteSchema = {}, + Singleton extends SingletonBase = { + decorator: {} + store: {} + derive: {} + resolve: {} + }, + Errors extends Record = {}, + Definitions extends DefinitionBase['typebox'] = {}, + K extends keyof MacroFn = keyof MacroFn +> = K extends any + ? MacroEntryForSelected + : never + interface RouteSchemaWithResolvedMacro extends RouteSchema { response: PossibleResponse return: PossibleResponse diff --git a/test/macro/macro.test.ts b/test/macro/macro.test.ts index 2dbd7d9d6..c77f88956 100644 --- a/test/macro/macro.test.ts +++ b/test/macro/macro.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { describe, it, expect } from 'bun:test' -import { Elysia, t, status } from '../../src' +import { describe, expect, it } from 'bun:test' +import { Elysia, status, t } from '../../src' import { post, req } from '../utils' describe('Macro', () => { @@ -447,7 +447,7 @@ describe('Macro', () => { const called = [] const plugin = new Elysia().get('/hello', () => 'hello', { - // @ts-ignore + // @ts-expect-error hello: 'nagisa' }) @@ -1438,4 +1438,286 @@ describe('Macro', () => { expect(invalid3.status).toBe(422) }) + + describe('Macro resolve dependency ordering', () => { + it('cross-plugin resolve dependency', async () => { + const order: string[] = [] + + const sessionsPlugin = new Elysia().macro({ + sessions: { + resolve: () => { + order.push('sessions') + return { + sessions: { + get: () => 'session-data', + create: () => {}, + delete: () => {} + } + } + } + } + }) + + const app = new Elysia() + .use(sessionsPlugin) + .macro({ + auth: { + sessions: true, + resolve: ({ sessions }) => { + order.push('auth') + const data = sessions.get() + return { auth: { currentUser: data } } + } + } + }) + .get( + '/', + ({ auth, sessions }) => ({ auth, hasSessions: !!sessions }), + { + auth: true + } + ) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(order).toEqual(['sessions', 'auth']) + expect(response.auth).toEqual({ currentUser: 'session-data' }) + expect(response.hasSessions).toBeTruthy() + }) + + it('chained dependencies (3 levels deep)', async () => { + const app = new Elysia() + .macro({ + a: { + resolve: () => ({ a: 'a' }) + }, + b: { + a: true, + resolve: ({ a }) => ({ b: a + 'b' }) + }, + c: { + b: true, + resolve: ({ b }) => ({ c: b + 'c' }) + } + }) + .get('/', ({ a, b, c }) => ({ a, b, c }), { + c: true + }) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(response).toEqual({ a: 'a', b: 'ab', c: 'abc' }) + }) + + it('multiple simultaneous dependencies', async () => { + const app = new Elysia() + .macro({ + x: { + resolve: () => ({ x: 'x-value' }) + }, + y: { + resolve: () => ({ y: 'y-value' }) + }, + combined: { + x: true, + y: true, + resolve: ({ x, y }) => ({ + combined: x + '+' + y + }) + } + }) + .get('/', ({ combined, x, y }) => ({ combined, x, y }), { + combined: true + }) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(response).toEqual({ + x: 'x-value', + y: 'y-value', + combined: 'x-value+y-value' + }) + }) + + it('property declaration order independence', async () => { + const app = new Elysia() + .macro({ + base: { + resolve: () => ({ base: 'base-value' }) + }, + depBefore: { + base: true, + resolve: ({ base }) => ({ depBefore: base + '-before' }) + }, + depAfter: { + resolve: ({ base }) => ({ depAfter: base + '-after' }), + base: true + } + }) + .get( + '/before', + ({ depBefore, base }) => ({ depBefore, base }), + { + depBefore: true + } + ) + .get('/after', ({ depAfter, base }) => ({ depAfter, base }), { + depAfter: true + }) + + const resBefore = await app + .handle(req('/before')) + .then((x) => x.json()) + const resAfter = await app + .handle(req('/after')) + .then((x) => x.json()) + + expect(resBefore).toEqual({ + base: 'base-value', + depBefore: 'base-value-before' + }) + expect(resAfter).toEqual({ + base: 'base-value', + depAfter: 'base-value-after' + }) + }) + + it('async resolve in dependency chain', async () => { + const app = new Elysia() + .macro({ + slow: { + resolve: async () => { + await new Promise((r) => setTimeout(r, 10)) + return { slow: 'slow-value' } + } + }, + fast: { + slow: true, + resolve: ({ slow }) => ({ fast: slow + '-fast' }) + } + }) + .get('/', ({ slow, fast }) => ({ slow, fast }), { + fast: true + }) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(response).toEqual({ + slow: 'slow-value', + fast: 'slow-value-fast' + }) + }) + + it('deduplication preserved with shared dependencies', async () => { + let baseCallCount = 0 + + const app = new Elysia() + .macro({ + base: { + resolve: () => { + baseCallCount++ + return { base: 'base-value' } + } + }, + ext1: { + base: true, + resolve: ({ base }) => ({ ext1: base + '-ext1' }) + }, + ext2: { + base: true, + resolve: ({ base }) => ({ ext2: base + '-ext2' }) + } + }) + .get('/', ({ base, ext1, ext2 }) => ({ base, ext1, ext2 }), { + ext1: true, + ext2: true + }) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(response).toEqual({ + base: 'base-value', + ext1: 'base-value-ext1', + ext2: 'base-value-ext2' + }) + expect(baseCallCount).toEqual(1) + }) + + it('guard with dependent macros', async () => { + const sessionsPlugin = new Elysia().macro({ + sessions: { + resolve: () => ({ + sessions: { get: () => 'guard-session' } + }) + } + }) + + const app = new Elysia() + .use(sessionsPlugin) + .macro({ + auth: { + sessions: true, + resolve: ({ sessions }) => ({ + auth: { user: sessions.get() } + }) + } + }) + .guard({ auth: true }, (app) => + app.get('/', ({ auth }) => auth) + ) + + const response = await app.handle(req('/')).then((x) => x.json()) + + expect(response).toEqual({ user: 'guard-session' }) + }) + + it('group with dependent macros', async () => { + const sessionsPlugin = new Elysia().macro({ + sessions: { + resolve: () => ({ + sessions: { get: () => 'group-session' } + }) + } + }) + + const app = new Elysia() + .use(sessionsPlugin) + .macro({ + auth: { + sessions: true, + resolve: ({ sessions }) => ({ + auth: { user: sessions.get() } + }) + } + }) + .group('/api', { auth: true }, (app) => + app.get('/', ({ auth }) => auth) + ) + + const response = await app + .handle(req('/api/')) + .then((x) => x.json()) + + expect(response).toEqual({ user: 'group-session' }) + }) + it('cycle between macros does not hang (stopped by iteration limit)', async () => { + // `as any` bypasses TS2589 — cyclic macros are correctly rejected at the + // type level by DiscriminatedMacroEntry; this test exercises the runtime guard. + const app = new Elysia() + .macro({ + a: { + b: true, // a depends on b + resolve: () => ({ a: 'a' }) + } as any, + b: { + a: true, // b depends on a ← cycle + resolve: () => ({ b: 'b' }) + } as any + }) + .get('/', () => 'ok', { a: true } as any) + + const response = await app.handle(req('/')) + expect(response.status).toBe(200) + }) + }) }) diff --git a/test/types/macro.ts b/test/types/macro.ts index 8575f259d..47bde22ae 100644 --- a/test/types/macro.ts +++ b/test/types/macro.ts @@ -319,3 +319,63 @@ const app = new Elysia() }) .listen(3000) } + +// Cross-plugin object macro: resolve receives dependent macro's resolved values +{ + const sessionsPlugin = new Elysia().macro({ + sessions: { + resolve: () => ({ + sessions: { + create: () => {}, + get: () => {}, + delete: () => {} + } + }) + } + }) + + new Elysia().use(sessionsPlugin).macro({ + auth: { + sessions: true, + resolve: ({ sessions }) => { + expectTypeOf().not.toBeAny() + expectTypeOf(sessions).toEqualTypeOf<{ + create: () => void + get: () => void + delete: () => void + }>() + + return { auth: { currentUser: sessions.get() } } + } + } + }) +} + +// Cross-plugin named macro: resolve receives dependent macro's resolved values (regression guard) +{ + const sessionsPlugin = new Elysia().macro({ + sessions: { + resolve: () => ({ + sessions: { + create: () => {}, + get: () => {}, + delete: () => {} + } + }) + } + }) + + new Elysia().use(sessionsPlugin).macro('auth', { + sessions: true, + resolve: ({ sessions }) => { + expectTypeOf().not.toBeAny() + expectTypeOf(sessions).toEqualTypeOf<{ + create: () => void + get: () => void + delete: () => void + }>() + + return { auth: { currentUser: sessions.get() } } + } + }) +}