diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 1b25c6eeee4..0e05707ef60 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { createHandler } from "@webiny/event-handler-core"; +import { HandlerApp } from "@webiny/event-handler-core"; import type { HandlerSetup } from "@webiny/event-handler-core"; import type { Context } from "@webiny/aws-sdk/types/index.js"; import { awsLambdaTransport } from "./AwsLambdaTransport.js"; @@ -14,11 +14,11 @@ export interface CreateLambdaHandlerOptions { * lives in {@link awsLambdaTransport}; everything else is the shared handler loop. */ export function createLambdaHandler(options: CreateLambdaHandlerOptions) { - const handle = createHandler({ + const app = HandlerApp.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => handle(event, context); + return (event: any, context?: Context): Promise => app.handle(event, context); } diff --git a/packages/event-handler-core/__tests__/EventType.test.ts b/packages/event-handler-core/__tests__/EventType.test.ts index 4f52a6ae4a4..91bb15c0848 100644 --- a/packages/event-handler-core/__tests__/EventType.test.ts +++ b/packages/event-handler-core/__tests__/EventType.test.ts @@ -3,7 +3,7 @@ import { EventHandler } from "~/features/events/EventHandler.js"; import { EventType } from "~/features/events/EventType.js"; import type { IEventHandler, EventContext } from "~/features/events/EventHandler.js"; import type { NextFunction } from "~/features/events/types.js"; -import { createHandler } from "~/features/events/createHandler.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; describe("EventType dispatch", () => { it("should route to correct handler based on canHandle", async () => { @@ -30,14 +30,14 @@ describe("EventType dispatch", () => { dependencies: [] }); - const invoke = createHandler({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await invoke({ + const result = await app.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const invoke = createHandler({ + const app = HandlerApp.init({ root: container => { container.register(httpType); } }); - await expect(invoke({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( + await expect(app.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( "No event type matched the incoming event" ); }); @@ -123,7 +123,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const invoke = createHandler({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await invoke({ + await app.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await invoke({ Records: [{}] })).toBe("other"); + expect(await app.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/HandlerApp.test.ts b/packages/event-handler-core/__tests__/HandlerApp.test.ts new file mode 100644 index 00000000000..2b64ee79baa --- /dev/null +++ b/packages/event-handler-core/__tests__/HandlerApp.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { EventHandler } from "~/features/events/EventHandler.js"; +import { EventType } from "~/features/events/EventType.js"; +import type { IEventType } from "~/features/events/EventType.js"; +import { ChildContainerFactory, RootContainerFactory } from "~/features/events/abstractions.js"; +import type { IEventHandler } from "~/features/events/EventHandler.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; + +describe("HandlerApp (DI-native handler app)", () => { + class HttpEventType implements IEventType { + canHandle(e: any): e is any { + return !!e.method; + } + getHandlerAbstraction() { + return EventHandler; + } + } + + const httpType = EventType.createImplementation({ + implementation: HttpEventType, + dependencies: [] + }); + + const httpEvent = { method: "GET", path: "/" }; + + const okHandler = () => { + class OkHandler implements IEventHandler { + async execute() { + return "ok"; + } + } + return EventHandler.createImplementation({ implementation: OkHandler, dependencies: [] }); + }; + + it("dispatches an event like the previous closure", async () => { + const app = HandlerApp.init({ + root: container => { + container.register(httpType); + container.register(okHandler()); + } + }); + + expect(await app.handle(httpEvent)).toBe("ok"); + }); + + it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { + const calls: string[] = []; + + class CountingChildContainerFactory implements ChildContainerFactory.Interface { + constructor(private decoratee: ChildContainerFactory.Interface) {} + async create(root: any, rawArgs: any[]) { + calls.push("before"); + const child = await this.decoratee.create(root, rawArgs); + calls.push("after"); + return child; + } + } + + const decorator = ChildContainerFactory.createDecorator({ + decorator: CountingChildContainerFactory, + dependencies: [] + }); + + const app = HandlerApp.init({ + root: container => { + container.register(httpType); + container.register(okHandler()); + }, + app: container => { + container.registerDecorator(decorator); + } + }); + + expect(await app.handle(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); + + // Decorator wraps create() once per request (before + after), twice over two invocations. + expect(calls).toEqual(["before", "after", "before", "after"]); + }); + + it("builds the root container once and reuses it across invocations", async () => { + let rootBuilds = 0; + + class CountingRootContainerFactory implements RootContainerFactory.Interface { + constructor(private decoratee: RootContainerFactory.Interface) {} + async get() { + const root = await this.decoratee.get(); + rootBuilds++; + return root; + } + } + + let rootSetupCalls = 0; + const decorator = RootContainerFactory.createDecorator({ + decorator: CountingRootContainerFactory, + dependencies: [] + }); + + const app = HandlerApp.init({ + root: container => { + rootSetupCalls++; + container.register(httpType); + container.register(okHandler()); + }, + app: container => { + container.registerDecorator(decorator); + } + }); + + await app.handle(httpEvent); + await app.handle(httpEvent); + + // get() is called per request, but the underlying root is built (root setup runs) only once. + expect(rootBuilds).toBe(2); + expect(rootSetupCalls).toBe(1); + }); + + it("getRootContainer builds the root eagerly and handle() reuses it", async () => { + let rootSetupCalls = 0; + + const app = HandlerApp.init({ + root: container => { + rootSetupCalls++; + container.register(httpType); + container.register(okHandler()); + } + }); + + const root1 = await app.getRootContainer(); + const root2 = await app.getRootContainer(); + await app.handle(httpEvent); + + // Same instance every time; root setup runs exactly once across getter + handle. + expect(root1).toBe(root2); + expect(rootSetupCalls).toBe(1); + }); +}); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index eb4ce1f6396..f57ba9816cf 100644 --- a/packages/event-handler-core/__tests__/RequestInitializer.test.ts +++ b/packages/event-handler-core/__tests__/RequestInitializer.test.ts @@ -5,7 +5,7 @@ import type { IEventType } from "~/features/events/EventType.js"; import { RequestInitializer } from "~/features/events/RequestInitializer.js"; import type { IEventHandler, EventContext } from "~/features/events/EventHandler.js"; import type { NextFunction } from "~/features/events/types.js"; -import { createHandler } from "~/features/events/createHandler.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const invoke = createHandler({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await invoke(httpEvent); + await app.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const invoke = createHandler({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await invoke(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/ChildContainerFactory.ts b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts new file mode 100644 index 00000000000..4e0c1316381 --- /dev/null +++ b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts @@ -0,0 +1,36 @@ +import { Container } from "@webiny/di"; +import { ChildContainerFactory, HandlerConfig } from "./abstractions.js"; +import { RequestContainer } from "./RequestContainer.js"; +import { RequestInitializer } from "./RequestInitializer.js"; +import { noopTransport } from "./Transport.js"; + +class ChildContainerFactoryImpl implements ChildContainerFactory.Interface { + constructor(private config: HandlerConfig.Interface) {} + + async create(root: Container, rawArgs: any[]): Promise { + const child = root.createChildContainer(); + child.registerInstance(RequestContainer, child); + + // Transport-specific bind: register the raw platform arguments into the request container + // before request setup runs. The default transport binds nothing. + const transport = this.config.transport ?? noopTransport; + await transport.bind(child, ...rawArgs); + + if (this.config.request) { + await this.config.request(child); + } + + // Per-request async initialization (tenant-agnostic), before the event is dispatched and + // before auth/tenant are established. For tenant-dependent setup use lazy DI factories. + for (const initializer of child.resolveAll(RequestInitializer)) { + await initializer.init(); + } + + return child; + } +} + +export const DefaultChildContainerFactory = ChildContainerFactory.createImplementation({ + implementation: ChildContainerFactoryImpl, + dependencies: [HandlerConfig] +}); diff --git a/packages/event-handler-core/src/features/events/HandlerApp.ts b/packages/event-handler-core/src/features/events/HandlerApp.ts new file mode 100644 index 00000000000..d47bd958dc6 --- /dev/null +++ b/packages/event-handler-core/src/features/events/HandlerApp.ts @@ -0,0 +1,90 @@ +import { Container } from "@webiny/di"; +import { EventType } from "./EventType.js"; +import { HandlerConfig, RootContainerFactory, ChildContainerFactory } from "./abstractions.js"; +import { DefaultRootContainerFactory } from "./RootContainerFactory.js"; +import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; +import { executeChain } from "./chain.js"; + +/** + * The DI-native handler app. `HandlerApp.init(config)` builds a small "app container" (distinct + * from the per-process root container and the per-request child container it goes on to create), + * wires the default lifecycle abstractions, and returns an app whose `handle()` is the + * platform-invocable handler. + * + * The lifecycle is delegated to decoratable DI abstractions — {@link RootContainerFactory} (build + * the root once) and {@link ChildContainerFactory} (create + set up the per-request child) — so + * transports/composition layers extend it by decoration (`config.app`) instead of this class + * growing new branches. `HandlerApp` is distinct from {@link EventHandler}, which is a single + * handler IN the dispatch chain. + */ +export class HandlerApp { + private constructor( + private rootContainerFactory: RootContainerFactory.Interface, + private childContainerFactory: ChildContainerFactory.Interface + ) {} + + static init(config: HandlerConfig.Interface): HandlerApp { + const appContainer = new Container(); + + // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. + appContainer.registerInstance(HandlerConfig, config); + + // Register the default lifecycle abstractions. Singleton-scoped so the memoized root + // container (held by RootContainerFactory) is shared across every warm invocation. + appContainer.register(DefaultRootContainerFactory).inSingletonScope(); + appContainer.register(DefaultChildContainerFactory).inSingletonScope(); + + // Seam: let callers decorate the factories before the app is resolved. + config.app?.(appContainer); + + // Resolve the factories once (decorators applied) so their state — notably the memoized + // root — is reused across every invocation of handle(). + return new HandlerApp( + appContainer.resolve(RootContainerFactory), + appContainer.resolve(ChildContainerFactory) + ); + } + + /** + * Build (once) and return the root container. Lets a transport that needs the root before the + * first request — e.g. the Node server attaching a WebSockets upgrade handler at startup — get + * it eagerly. The same memoized root is then reused by every `handle()` call. + */ + getRootContainer(): Promise { + return this.rootContainerFactory.get(); + } + + async handle(...rawArgs: any[]): Promise { + const root = await this.rootContainerFactory.get(); + const child = await this.childContainerFactory.create(root, rawArgs); + + // The event to match on is always the first raw argument (transports never change this). + const event = rawArgs[0]; + const eventTypes = child.resolveAll(EventType); + const matched = eventTypes.find(et => et.canHandle(event)); + + if (!matched) { + // Include a non-sensitive shape summary so this is debuggable: which event types were + // registered vs. what the event actually looks like (keys + EventBridge discriminators). + const shape = + event && typeof event === "object" + ? { + keys: Object.keys(event), + source: (event as any).source, + detailType: (event as any)["detail-type"] + } + : { type: typeof event }; + const registered = eventTypes.map(et => (et as any)?.constructor?.name); + throw new Error( + `No event type matched the incoming event. Event shape: ${JSON.stringify( + shape + )}; registered event types: ${JSON.stringify(registered)}` + ); + } + + const abstraction = matched.getHandlerAbstraction(); + const handlers = child.resolveAll(abstraction); + + return executeChain(handlers, event); + } +} diff --git a/packages/event-handler-core/src/features/events/RootContainerFactory.ts b/packages/event-handler-core/src/features/events/RootContainerFactory.ts new file mode 100644 index 00000000000..c49dd164cd8 --- /dev/null +++ b/packages/event-handler-core/src/features/events/RootContainerFactory.ts @@ -0,0 +1,21 @@ +import { Container } from "@webiny/di"; +import { HandlerConfig, RootContainerFactory } from "./abstractions.js"; + +class RootContainerFactoryImpl implements RootContainerFactory.Interface { + private rootContainer: Container | null = null; + + constructor(private config: HandlerConfig.Interface) {} + + async get(): Promise { + if (!this.rootContainer) { + this.rootContainer = new Container(); + await this.config.root(this.rootContainer); + } + return this.rootContainer; + } +} + +export const DefaultRootContainerFactory = RootContainerFactory.createImplementation({ + implementation: RootContainerFactoryImpl, + dependencies: [HandlerConfig] +}); diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts new file mode 100644 index 00000000000..74d3469ccf4 --- /dev/null +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -0,0 +1,69 @@ +import { Abstraction, Container } from "@webiny/di"; +import type { Transport } from "./Transport.js"; +import type { HandlerSetup } from "./types.js"; + +/** + * Configuration for `HandlerApp.init` — and the DI value the default lifecycle abstractions + * ({@link RootContainerFactory} / {@link ChildContainerFactory}) resolve. The object passed to + * `init` is registered as-is under this abstraction (no remapping), so the config the caller writes + * is exactly the config the factories read. + */ +export interface IHandlerConfig { + root: HandlerSetup; + request?: HandlerSetup; + /** + * Transport-specific extract step: binds the raw platform arguments (e.g. the AWS Lambda + * event + context) into the per-request container. Defaults to a no-op, which leaves the + * event to pass straight through — the plain server/HTTP behavior. + */ + transport?: Transport; + /** + * Decorate the DI-native handler app before its first use. Runs against the APP container (the + * small container holding the lifecycle abstractions), so callers can `registerDecorator(...)` + * around any lifecycle step — e.g. wrapping `ChildContainerFactory` to refresh a license before + * each request. + */ + app?: (container: Container) => void; +} + +export const HandlerConfig = new Abstraction("HandlerConfig"); + +export namespace HandlerConfig { + export type Interface = IHandlerConfig; +} + +/** + * Builds the ROOT container once per process and reuses it across warm invocations. Decoratable — + * wrap it to run process-lifetime setup around the root build. `get()` is idempotent — the root is + * built (and `config.root` run) on the first call and the same instance is returned thereafter, so a + * transport can call it eagerly (see `HandlerApp.getRootContainer`) without rebuilding. + */ +export interface IRootContainerFactory { + get(): Promise; +} + +export const RootContainerFactory = new Abstraction("RootContainerFactory"); + +export namespace RootContainerFactory { + export type Interface = IRootContainerFactory; +} + +/** + * Creates and sets up the per-request (child) container: spawns the child, binds transport + * primitives, runs request setup, and runs the pre-dispatch {@link RequestInitializer} loop. + * + * Decoratable — this is the seam for per-request work that must run BEFORE the register/dispatch + * flow (e.g. refreshing a project-level license so register-time checks see it). Since such work + * typically needs only root-scoped state, a decorator can act before delegating to `create()`. + */ +export interface IChildContainerFactory { + create(root: Container, rawArgs: any[]): Promise; +} + +export const ChildContainerFactory = new Abstraction( + "ChildContainerFactory" +); + +export namespace ChildContainerFactory { + export type Interface = IChildContainerFactory; +} diff --git a/packages/event-handler-core/src/features/events/createHandler.ts b/packages/event-handler-core/src/features/events/createHandler.ts deleted file mode 100644 index 74b7e6838a7..00000000000 --- a/packages/event-handler-core/src/features/events/createHandler.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Container } from "@webiny/di"; -import { EventType } from "~/features/events/EventType.js"; -import { RequestContainer } from "~/features/events/RequestContainer.js"; -import { RequestInitializer } from "~/features/events/RequestInitializer.js"; -import { executeChain } from "~/features/events/chain.js"; -import { noopTransport } from "~/features/events/Transport.js"; -import type { Transport } from "~/features/events/Transport.js"; -import type { HandlerSetup } from "~/features/events/types.js"; - -export interface CreateHandlerOptions { - root: HandlerSetup; - request?: HandlerSetup; - /** - * Transport-specific extract step: binds the raw platform arguments (e.g. the AWS Lambda - * event + context) into the per-request container. Defaults to a no-op, which leaves the - * event to pass straight through — the plain server/HTTP behavior. - */ - transport?: Transport; - /** - * A pre-built, already root-initialized container. When provided, `root` is NOT called again — - * used by transports that must build the root eagerly at startup (e.g. the Node server, which - * needs the root container ready to attach a WebSockets upgrade handler before the first request). - */ - rootContainer?: Container; -} - -export function createHandler(options: CreateHandlerOptions) { - let rootContainer: Container | null = options.rootContainer ?? null; - const transport = options.transport ?? noopTransport; - - return async (...rawArgs: any[]): Promise => { - if (!rootContainer) { - rootContainer = new Container(); - await options.root(rootContainer); - } - - const child = rootContainer.createChildContainer(); - child.registerInstance(RequestContainer, child); - - // Transport-specific bind: register the raw platform arguments into the request container - // before request setup runs. The default transport binds nothing. - await transport.bind(child, ...rawArgs); - - if (options.request) { - await options.request(child); - } - - // Per-request async initialization (tenant-agnostic), before the event is dispatched and - // before auth/tenant are established. For tenant-dependent setup use lazy DI factories. - for (const initializer of child.resolveAll(RequestInitializer)) { - await initializer.init(); - } - - // The event to match on is always the first raw argument (transports never change this). - const event = rawArgs[0]; - const eventTypes = child.resolveAll(EventType); - const matched = eventTypes.find(et => et.canHandle(event)); - - if (!matched) { - // Include a non-sensitive shape summary so this is debuggable: which event types were - // registered vs. what the event actually looks like (keys + EventBridge discriminators). - const shape = - event && typeof event === "object" - ? { - keys: Object.keys(event), - source: (event as any).source, - detailType: (event as any)["detail-type"] - } - : { type: typeof event }; - const registered = eventTypes.map(et => (et as any)?.constructor?.name); - throw new Error( - `No event type matched the incoming event. Event shape: ${JSON.stringify( - shape - )}; registered event types: ${JSON.stringify(registered)}` - ); - } - - const abstraction = matched.getHandlerAbstraction(); - const handlers = child.resolveAll(abstraction); - - return executeChain(handlers, event); - }; -} diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index 51847670409..3529cd785b4 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -4,7 +4,10 @@ export * from "./RequestContainer.js"; export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; -export * from "./createHandler.js"; +export * from "./abstractions.js"; +export * from "./HandlerApp.js"; +export * from "./RootContainerFactory.js"; +export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; export * from "./types.js"; export * from "./runRequestContextInitializers.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index b713806c3c3..e42cd7d5739 100644 --- a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts +++ b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts @@ -1,6 +1,6 @@ import { HttpFeature } from "~/features/http/feature.js"; import { HttpRouterHandler } from "./HttpRouterHandler.js"; -import { createHandler } from "~/features/events/createHandler.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; import { TestHttpEventType } from "./TestHttpEventType.js"; import type { HandlerSetup, IHttpRequest, IHttpResponse } from "~/index.js"; @@ -15,7 +15,7 @@ export interface createTestHttpHandlerOptions { * Callers layer middleware on top via container.registerDecorator() in options.root. */ export function createTestHttpHandler(options: createTestHttpHandlerOptions) { - const invoke = createHandler({ + const app = HandlerApp.init({ root: async container => { container.register(TestHttpEventType); container.register(HttpRouterHandler); @@ -28,7 +28,7 @@ export function createTestHttpHandler(options: createTestHttpHandlerOptions) { return async ( request: Partial & { method: string; path: string } ): Promise => { - return invoke({ + return app.handle({ method: request.method, path: request.path, headers: request.headers ?? {}, diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index 221b1f582e5..5ac4d59f28e 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -1,6 +1,6 @@ import http from "node:http"; import { Container } from "@webiny/di"; -import { createHandler } from "@webiny/event-handler-core"; +import { HandlerApp } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -17,20 +17,14 @@ export interface CreateServerHandlerOptions { export async function createServerHandler( options: CreateServerHandlerOptions ): Promise { - // Build the root container eagerly (rather than lazily on the first request) so `onServer` can - // hand it — and the running HTTP server — to transport add-ons like WebSockets at startup. - const rootContainer = new Container(); - await options.root(rootContainer); - - const handle = createHandler({ + const app = HandlerApp.init({ root: options.root, - request: options.request, - rootContainer + request: options.request }); const server = http.createServer(async (req, res) => { try { - const response = (await handle(req)) as IHttpResponse; + const response = (await app.handle(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; if (body === undefined || body === null) { @@ -54,6 +48,9 @@ export async function createServerHandler( }); if (options.onServer) { + // Build the root container eagerly (rather than lazily on the first request) so `onServer` can + // hand it — and the running HTTP server — to transport add-ons like WebSockets at startup. + const rootContainer = await app.getRootContainer(); await options.onServer(server, rootContainer); } diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md new file mode 100644 index 00000000000..bab5ded78da --- /dev/null +++ b/plans/licensing-feature-flags.md @@ -0,0 +1,165 @@ +# Plan: DI-native handler + licensing via feature flags + +> Source: design discussion (Adrian + Pavel). No formal PRD — this file is the agreed design. +> Related: [[project_register_time_wcp_gate_bug]], [[project_request_lifecycle_di]], [[project_api_infra_layer]], [[reference_container_capture_root_vs_request]]. Tag every PR `evh-cleanups`. + +## Goal + +Two intertwined refactors: + +- **B (handler):** turn the event handler into a DI-native app with decoratable lifecycle seams. Kill the `RequestInitializer` bag. +- **A (licensing):** remove WCP from all `api-*` packages. `api-*` read only `FeatureFlags`. The live WCP license is merged into `FeatureFlags` **before** the per-request register phase, so register-time gating (`if (flags.isXEnabled()) register(...)`) is valid again. + +B ships first (Adrian wants the DI-native handler in action), and its `ChildContainerFactory` seam is where A's license-refresh decorator lands. + +--- + +## Architectural decisions + +Durable across all phases: + +- **License = ceiling, flags pull down.** `final = license ∧ userFlag`. A user flag can DISABLE a licensed WCP feature (e.g. threat detection off in an infra-heavy env) but cannot enable an unlicensed one. Already coded in `applyLicense` / `applyLicenseFlag`. +- **`FeatureFlags` (api-core) is the single read surface** for `api-*`. Zero WCP imports in `api-*` after migration. `FeatureFlags.get()` stays **sync**. +- **License refresh runs PRE-register** — moved off the post-register `RequestInitializer` loop. This is the enabling change; it is exactly what #5523 lacked (refresh ran after register → `NullLicense` at register → gate always false). +- **`WcpLicenseProvider` = ROOT singleton, single-flight.** Root so concurrent child requests share it (dedup concurrent WCP calls) and so it exists before the per-request register phase. Single-flight = memoized in-flight promise; TTL cache (5 min) handles steady state, the promise closes the concurrent-expiry race. +- **Register-time gating restored.** The clean `if (flags.isPrivateFilesEnabled()) { register... }` shape returns; #5523's runtime pass-through guards are reverted. +- **Custom (non-WCP) flags — PARKED.** Today `IFeatureFlagsDto` + `FeatureFlags` accessors are a fixed WCP enum. A generic `isEnabled(key)` + open `custom` slot is deferred until needed. +- **Handler abstractions (new):** `HandlerApp` (app-level orchestrator — note `EventHandler`/`IEventHandler` is already the per-event *chain* handler, distinct), `RootContainerFactory`, `ChildContainerFactory`. Factories own container *creation* (so a decorator wraps make+populate). They live in an **app container** built in `createHandler` (3 containers total: app + root + child). + +--- + +## [ ] Phase 1: DI-native handler app (behavior-preserving) + +**Goal:** replace the `createHandler` closure with a DI-native app: `HandlerApp` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. + +### What to build + +- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `HandlerApp`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. +- `createHandler` becomes thin: build app container → register defaults → `resolve(HandlerApp)` → return `(...rawArgs) => runtime.handle(rawArgs)`. +- `RootContainerFactory.get(): Container` — lazy-once root build (honors the prebuilt-`rootContainer` path the Node server uses for eager WS-upgrade wiring). Decoratable. +- `ChildContainerFactory.create(root, rawArgs): Container` — owns: `createChildContainer()` + `registerInstance(RequestContainer, child)` + `transport.bind(child, ...rawArgs)` + `options.request(child)` + (for now) the `RequestInitializer` loop. Decoratable. +- `HandlerApp.handle(rawArgs)` — orchestrates: `root = rootContainerFactory.get()`; `child = childContainerFactory.create(root, rawArgs)`; event-type match; `executeChain`. Decoratable. +- `RequestInitializer` loop stays (relocated inside `create`) — dies in Phase 3. + +### Acceptance criteria + +- [ ] `createHandler` builds an app container and resolves `HandlerApp`; the returned invocable behaves identically to today. +- [ ] AWS (`createLambdaHandler`/`createWebinyApiHandler`) and Node server handlers work unchanged (root/request/transport wiring intact, incl. prebuilt-root path). +- [ ] All existing event-handler-core tests pass (chain, EventType, RequestInitializer, TestHttpEventHandler). +- [ ] A test decorates `ChildContainerFactory` and observes the decorator running per request (proves the seam). +- [ ] `RequestInitializer` semantics unchanged (still runs before dispatch, after register). + +--- + + + +## [x] Phase 2: Decisions (RESOLVED) + +**Goal:** resolve the two open questions that change Phase 3/5 wiring. + +### Decision 1 — build-time `GetFeatureFlagsWithLicense`: leave as-is (no conflict) + +Verified: there is NO conflict. Two independent paths: +- **API runtime:** `FeatureFlags.tsx` renders `` = **raw user webiny.config flags, no license**. api-core `FeatureFlagsImpl` reads exactly that. License is applied at REQUEST time by `WcpContextWithFeatureFlagsDecorator` (`canUseX() = realLicense.canUseX() && userFlag`). So BuildParams is already user-flags-only — the desired state. +- **CLI/ProjectSdk:** `GetFeatureFlagsWithLicense` decorates `project.getFeatureFlags()` (reads `WCP_PROJECT_LICENSE` env), used by the SDK/deploy side — it NEVER feeds api BuildParams. Independent, legit deploy-time license view. + +→ Leave `GetFeatureFlagsWithLicense` untouched; BuildParams stays user-flags-only. Phase 3 just relocates the *runtime* merge from `WcpContextWithFeatureFlagsDecorator` onto `FeatureFlags`. No build-side change. + +### Decision 2 — add a dedicated `featureFlags` gql query; demote `wcp` + +Admin should know feature flags, not WCP. +- **New `featureFlags` query** (a `CoreGraphQLSchemaFactory` contributor in api-core) — returns the merged effective flags (`FeatureFlags.get()` → license ∧ userFlag after Phase 3). Admin queries this at init and gates UI on booleans. Never exposes WCP. +- **`wcp` query (`WcpSchemaFactory`)** — stops being the feature-gating surface. Audit what admin actually reads from `wcp`/`getProjectWithFeatureFlags`: if only feature booleans → the `wcp` query can be deleted once admin moves to `featureFlags`; if it also renders seats/tenants/expiry/plan → keep it as a license-DETAIL query, resolver repointed (Phase 5) to source from the `licensing` package (License provider) instead of the deleted `WcpContext`. + +### Acceptance criteria + +- [x] Decision 1 recorded: no build-side change; BuildParams = user flags; runtime merges license. +- [x] Decision 2 recorded: add `featureFlags` query for admin gating; audit + demote/repoint `wcp` query. + +### Follow-up TODO (feeds Phase 3/5) + +- [ ] Add the `featureFlags` gql query (Phase 3, alongside the FeatureFlags merge). +- [ ] Audit admin's `wcp`/`getProjectWithFeatureFlags` field usage → decide delete vs keep-as-license-detail. + +--- + +## [ ] Phase 3: Licensing tracer bullet (private files, end-to-end) + +**Goal:** prove the whole chain with one call site. This fixes the #5523 root cause. + +### What to build + +- New `licensing` behavior (still inside api-core for now, extracted in Phase 6): + - `WcpLicenseProvider` → ROOT singleton + single-flight (`inflight` promise + TTL cache). + - License refresh becomes a **decorator on `ChildContainerFactory`** (from Phase 1): `await provider.refresh()` then delegate to `create()`. Runs pre-register; provider is child-independent so no split needed. +- `FeatureFlags` becomes the merge point: relocate `WcpContextWithFeatureFlagsDecorator`'s merge to decorate **`FeatureFlags`** (user flags ∧ live license). `get()` stays sync (refresh already awaited). +- Migrate **one** call site — private files (`AssetDeliveryFeature`): `WcpContext.canUsePrivateFiles()` → `FeatureFlags.get().isPrivateFilesEnabled()`. **Revert** #5523's runtime guard in `PrivateFilesAssetProcessor` (drop injected `WcpContext` + passthrough). +- Remove the WCP-refresh `RequestInitializer`; **`RequestInitializer` can now be deleted** (last member gone — verify no other members remain from Phase 1's inventory). + +### Acceptance criteria + +- [ ] Deploy-verify: licensed → private files works; flag off → public delivery only; register-time gate sees live license. +- [ ] License change (upgrade/downgrade) takes effect on the **next request** within the 5-min TTL, no redeploy. +- [ ] Two concurrent requests on a cold cache trigger exactly **one** WCP call (single-flight). +- [ ] `grep WcpContext packages/api-file-manager` → zero hits in the private-files path. +- [ ] `RequestInitializer` deleted (or its remaining non-WCP members converted). + +--- + +## [ ] Phase 4: Fan out call-site migration (mechanical) + +**Goal:** migrate every remaining `WcpContext` reader to `FeatureFlags`. + +### What to build + +- Inventory first: `grep` for `resolve(WcpContext)` + `canUse*(` across `api-*` and `packages/*`. +- Per site: `WcpContext.canUseX()` → `FeatureFlags.get().isXEnabled()`. Revert any #5523-style runtime guard back to a register-time gate. +- Known sites: fm-s3 threat detection (`CreateFileWithThreatScanDecorator` → drop dep, re-gate in `FileManagerS3Feature`), audit-logs, aco, record-locking, workflows, ai-powerups (already trigger-time gated — align to flags). + +### Acceptance criteria + +- [ ] Every `WcpContext.canUse*()` call site reads `FeatureFlags` instead. +- [ ] All #5523 runtime guards reverted to register-time gates where the shape allows. +- [ ] Package builds green; existing tests pass. + +--- + + + +## [ ] Phase 5: Delete WcpContext machinery + +**Goal:** remove the dead request-time WCP path from api-core. + +### What to build + +- Confirm zero `WcpContext` readers remain. +- Delete: `WcpContext` + decorators (incl. `WcpContextWithFeatureFlagsDecorator` if its merge fully moved to `FeatureFlags`), `WcpFeature`, the old post-register `WcpLicenseInitializer`. + +### Acceptance criteria + +- [ ] `grep WcpContext packages/api-*` → zero hits. +- [ ] api-core builds without the WCP request-time path; tests pass. + +--- + +## [ ] Phase 6: Extract `licensing` package + +**Goal:** move WCP loading + license merge out of api-core into a dedicated `licensing` package. + +### What to build + +- New `@webiny/licensing` (name TBD): `WcpLicenseProvider` (single-flight), `loadWcpLicense`, the `FeatureFlags` license-merge decorator, the `ChildContainerFactory` refresh decorator. +- api-core keeps only the `FeatureFlags` abstraction (contract) + its BuildParams-backed default. `licensing` supplies the license-merge decorator and is wired by the composition/`api-infra` layer. +- `api-*` depend on zero WCP. + +### Acceptance criteria + +- [ ] `@webiny/wcp` referenced only by `licensing` (and CLI/build side), not by any `api-*` runtime package. +- [ ] `licensing` wires the refresh decorator onto `ChildContainerFactory` at the composition root. +- [ ] Full build + tests green; `yarn verify-dependencies` passes.