From a859ad1cb896d11cf6085ae9b0b77036a2a75b97 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 14:50:23 +0200 Subject: [PATCH 01/16] refactor(event-handler-core): make the handler a DI-native app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `createHandler` closure with a small DI app built in an "app container" (distinct from the per-process root and per-request child containers it goes on to create). Three decoratable abstractions own the lifecycle: - HandlerRuntime — orchestrates root -> child -> event match -> dispatch - RootContainerFactory — builds/memoizes the root once per process - ChildContainerFactory — creates + sets up the per-request child (transport bind, request setup, RequestInitializer loop) Behavior-preserving: the external `createHandler` option surface is unchanged (only an additive `app?` decoration hook is added), and RequestInitializer still runs where it did. The new seams let the composition layer extend the lifecycle by decoration instead of growing branches in createHandler — e.g. a future pre-register license refresh decorator on ChildContainerFactory. First slice of plans/licensing-feature-flags.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../__tests__/HandlerRuntime.test.ts | 118 +++++++++++++++ .../features/events/ChildContainerFactory.ts | 54 +++++++ .../src/features/events/HandlerConfig.ts | 26 ++++ .../src/features/events/HandlerRuntime.ts | 70 +++++++++ .../features/events/RootContainerFactory.ts | 40 +++++ .../src/features/events/createHandler.ts | 96 +++++------- .../src/features/events/index.ts | 3 + plans/licensing-feature-flags.md | 143 ++++++++++++++++++ 8 files changed, 493 insertions(+), 57 deletions(-) create mode 100644 packages/event-handler-core/__tests__/HandlerRuntime.test.ts create mode 100644 packages/event-handler-core/src/features/events/ChildContainerFactory.ts create mode 100644 packages/event-handler-core/src/features/events/HandlerConfig.ts create mode 100644 packages/event-handler-core/src/features/events/HandlerRuntime.ts create mode 100644 packages/event-handler-core/src/features/events/RootContainerFactory.ts create mode 100644 plans/licensing-feature-flags.md diff --git a/packages/event-handler-core/__tests__/HandlerRuntime.test.ts b/packages/event-handler-core/__tests__/HandlerRuntime.test.ts new file mode 100644 index 00000000000..438811ab5fa --- /dev/null +++ b/packages/event-handler-core/__tests__/HandlerRuntime.test.ts @@ -0,0 +1,118 @@ +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 } from "~/features/events/ChildContainerFactory.js"; +import { RootContainerFactory } from "~/features/events/RootContainerFactory.js"; +import type { IEventHandler } from "~/features/events/EventHandler.js"; +import { createHandler } from "~/features/events/createHandler.js"; + +describe("HandlerRuntime (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 through the runtime like the previous closure", async () => { + const invoke = createHandler({ + root: container => { + container.register(httpType); + container.register(okHandler()); + } + }); + + expect(await invoke(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 invoke = createHandler({ + root: container => { + container.register(httpType); + container.register(okHandler()); + }, + app: container => { + container.registerDecorator(decorator); + } + }); + + expect(await invoke(httpEvent)).toBe("ok"); + expect(await invoke(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 invoke = createHandler({ + root: container => { + rootSetupCalls++; + container.register(httpType); + container.register(okHandler()); + }, + app: container => { + container.registerDecorator(decorator); + } + }); + + await invoke(httpEvent); + await invoke(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); + }); +}); 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..eac62124ce4 --- /dev/null +++ b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts @@ -0,0 +1,54 @@ +import { Abstraction, Container } from "@webiny/di"; +import { HandlerConfig } from "./HandlerConfig.js"; +import { RequestContainer } from "./RequestContainer.js"; +import { RequestInitializer } from "./RequestInitializer.js"; + +/** + * 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; +} + +class ChildContainerFactoryImpl implements IChildContainerFactory { + 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. + await this.config.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/HandlerConfig.ts b/packages/event-handler-core/src/features/events/HandlerConfig.ts new file mode 100644 index 00000000000..ae0706810f5 --- /dev/null +++ b/packages/event-handler-core/src/features/events/HandlerConfig.ts @@ -0,0 +1,26 @@ +import { Abstraction, Container } from "@webiny/di"; +import type { Transport } from "./Transport.js"; +import type { HandlerSetup } from "./types.js"; + +/** + * Internal plumbing: the `createHandler` options, exposed as a DI value so the default + * {@link RootContainerFactory} / {@link ChildContainerFactory} implementations can resolve them + * (and stay decoratable). Not part of the public handler API — callers configure via + * `createHandler` options, not by resolving this. + */ +export interface IHandlerConfig { + root: HandlerSetup; + request?: HandlerSetup; + transport: Transport; + /** + * A pre-built, already root-initialized container. When set, the root is NOT built again — the + * Node server uses this to build the root eagerly at startup (WebSockets upgrade wiring). + */ + rootContainer: Container | null; +} + +export const HandlerConfig = new Abstraction("HandlerConfig"); + +export namespace HandlerConfig { + export type Interface = IHandlerConfig; +} diff --git a/packages/event-handler-core/src/features/events/HandlerRuntime.ts b/packages/event-handler-core/src/features/events/HandlerRuntime.ts new file mode 100644 index 00000000000..8108be5a415 --- /dev/null +++ b/packages/event-handler-core/src/features/events/HandlerRuntime.ts @@ -0,0 +1,70 @@ +import { Abstraction } from "@webiny/di"; +import { EventType } from "./EventType.js"; +import { RootContainerFactory } from "./RootContainerFactory.js"; +import { ChildContainerFactory } from "./ChildContainerFactory.js"; +import { executeChain } from "./chain.js"; + +/** + * The DI-native handler app: the top-level orchestrator returned (indirectly) by `createHandler`. + * It owns the whole per-invocation flow — obtain the root container, create the per-request child, + * match the incoming event to its {@link EventType}, and dispatch through the handler chain. + * + * Decoratable (distinct from {@link EventHandler}, which is a single handler IN the dispatch chain). + * The root and child container steps are delegated to the {@link RootContainerFactory} / + * {@link ChildContainerFactory} abstractions so each is independently decoratable. + */ +export interface IHandlerRuntime { + handle(rawArgs: any[]): Promise; +} + +export const HandlerRuntime = new Abstraction("HandlerRuntime"); + +export namespace HandlerRuntime { + export type Interface = IHandlerRuntime; +} + +class HandlerRuntimeImpl implements IHandlerRuntime { + constructor( + private rootContainerFactory: RootContainerFactory.Interface, + private childContainerFactory: ChildContainerFactory.Interface + ) {} + + 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); + } +} + +export const DefaultHandlerRuntime = HandlerRuntime.createImplementation({ + implementation: HandlerRuntimeImpl, + dependencies: [RootContainerFactory, ChildContainerFactory] +}); 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..a37f3c18ebd --- /dev/null +++ b/packages/event-handler-core/src/features/events/RootContainerFactory.ts @@ -0,0 +1,40 @@ +import { Abstraction, Container } from "@webiny/di"; +import { HandlerConfig } from "./HandlerConfig.js"; + +/** + * 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. + * + * When {@link HandlerConfig.rootContainer} is supplied (the Node server builds the root eagerly at + * startup), that container is returned as-is and `config.root` is NOT called again. + */ +export interface IRootContainerFactory { + get(): Promise; +} + +export const RootContainerFactory = new Abstraction("RootContainerFactory"); + +export namespace RootContainerFactory { + export type Interface = IRootContainerFactory; +} + +class RootContainerFactoryImpl implements IRootContainerFactory { + private rootContainer: Container | null; + + constructor(private config: HandlerConfig.Interface) { + this.rootContainer = config.rootContainer ?? null; + } + + 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/createHandler.ts b/packages/event-handler-core/src/features/events/createHandler.ts index 74b7e6838a7..3cb8427b46b 100644 --- a/packages/event-handler-core/src/features/events/createHandler.ts +++ b/packages/event-handler-core/src/features/events/createHandler.ts @@ -1,11 +1,11 @@ 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"; +import { HandlerConfig } from "./HandlerConfig.js"; +import { HandlerRuntime, DefaultHandlerRuntime } from "./HandlerRuntime.js"; +import { DefaultRootContainerFactory } from "./RootContainerFactory.js"; +import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; +import { noopTransport } from "./Transport.js"; +import type { Transport } from "./Transport.js"; +import type { HandlerSetup } from "./types.js"; export interface CreateHandlerOptions { root: HandlerSetup; @@ -22,62 +22,44 @@ export interface CreateHandlerOptions { * needs the root container ready to attach a WebSockets upgrade handler before the first request). */ rootContainer?: Container; + /** + * Decorate the DI-native handler app before its first use. Runs against the APP container (the + * small container holding {@link HandlerRuntime}, {@link RootContainerFactory} and + * {@link ChildContainerFactory}), so callers can `registerDecorator(...)` around any lifecycle + * step — e.g. wrapping `ChildContainerFactory` to refresh a license before each request. + */ + app?: (container: Container) => void; } +/** + * Wires the DI-native handler app and returns the platform-invocable handler. + * + * The handler is itself a small DI app living in an "app container" (distinct from the per-process + * root container and the per-request child container it goes on to create). `HandlerRuntime` owns + * the flow; `RootContainerFactory` and `ChildContainerFactory` own container creation. Each is a + * decoratable abstraction, so transports/composition layers extend the lifecycle without this + * function growing new branches. + */ 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); - } + const appContainer = new Container(); - // 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(); - } + appContainer.registerInstance(HandlerConfig, { + root: options.root, + request: options.request, + transport: options.transport ?? noopTransport, + rootContainer: options.rootContainer ?? null + }); - // 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)); + // 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(); + appContainer.register(DefaultHandlerRuntime).inSingletonScope(); - 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)}` - ); - } + // Seam: let callers decorate the runtime / factories before the app is resolved. + options.app?.(appContainer); - const abstraction = matched.getHandlerAbstraction(); - const handlers = child.resolveAll(abstraction); + const runtime = appContainer.resolve(HandlerRuntime); - return executeChain(handlers, event); - }; + return (...rawArgs: any[]): Promise => runtime.handle(rawArgs); } diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index 51847670409..f8256bd890d 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,6 +5,9 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./createHandler.js"; +export * from "./HandlerRuntime.js"; +export * from "./RootContainerFactory.js"; +export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; export * from "./types.js"; export * from "./runRequestContextInitializers.js"; diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md new file mode 100644 index 00000000000..9a397f23dcc --- /dev/null +++ b/plans/licensing-feature-flags.md @@ -0,0 +1,143 @@ +# 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):** `HandlerRuntime` (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: `HandlerRuntime` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. + +### What to build + +- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `HandlerRuntime`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. +- `createHandler` becomes thin: build app container → register defaults → `resolve(HandlerRuntime)` → 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. +- `HandlerRuntime.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 `HandlerRuntime`; 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). + +--- + +## [ ] Phase 2: Decisions (blocks correctness of Phase 3+) + +**Goal:** resolve the two open questions that change Phase 3 wiring. + +### What to build + +Decisions only, recorded in this file: + +1. **Build-time `project/GetFeatureFlagsWithLicense`** (bakes `WCP_PROJECT_LICENSE` env into BuildParams) now conflicts with the live runtime refresh — it would bake a stale license. Decide: BuildParams carries **user flags only**; runtime applies the license. Confirm whether the build-time merge is still needed for any consumer (CLI/admin scaffold) or is removed. +2. **`wcp` gql query (`WcpSchemaFactory` in `ApiCoreFeature`)** — admin reads it. Decide: keep and re-back it by the merged `FeatureFlags`, or admin reads flags via its own path (admin already receives merged features via gql at init). + +### Acceptance criteria + +- [ ] Decision 1 recorded; follow-up scoped (remove or retarget `GetFeatureFlagsWithLicense`). +- [ ] Decision 2 recorded; `wcp` query fate scoped. + +--- + +## [ ] 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. From 5d1fe6bd4c77d6734fe560a6668a3558e5d84bf2 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 15:29:58 +0200 Subject: [PATCH 02/16] refactor(event-handler-core): unify createHandler options with HandlerConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the separate `CreateHandlerOptions` interface; `createHandler` now takes `HandlerConfig` directly and registers it as-is (no field remapping). The config the caller writes is exactly the DI value the lifecycle factories resolve — the connection is visible at a glance. Transport defaulting moves into ChildContainerFactory. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../features/events/ChildContainerFactory.ts | 4 +- .../src/features/events/HandlerConfig.ts | 29 ++++++++++---- .../src/features/events/createHandler.ts | 39 ++----------------- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/packages/event-handler-core/src/features/events/ChildContainerFactory.ts b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts index eac62124ce4..7265b6c8fe4 100644 --- a/packages/event-handler-core/src/features/events/ChildContainerFactory.ts +++ b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts @@ -2,6 +2,7 @@ import { Abstraction, Container } from "@webiny/di"; import { HandlerConfig } from "./HandlerConfig.js"; import { RequestContainer } from "./RequestContainer.js"; import { RequestInitializer } from "./RequestInitializer.js"; +import { noopTransport } from "./Transport.js"; /** * Creates and sets up the per-request (child) container: spawns the child, binds transport @@ -32,7 +33,8 @@ class ChildContainerFactoryImpl implements IChildContainerFactory { // Transport-specific bind: register the raw platform arguments into the request container // before request setup runs. The default transport binds nothing. - await this.config.transport.bind(child, ...rawArgs); + const transport = this.config.transport ?? noopTransport; + await transport.bind(child, ...rawArgs); if (this.config.request) { await this.config.request(child); diff --git a/packages/event-handler-core/src/features/events/HandlerConfig.ts b/packages/event-handler-core/src/features/events/HandlerConfig.ts index ae0706810f5..95190709e9e 100644 --- a/packages/event-handler-core/src/features/events/HandlerConfig.ts +++ b/packages/event-handler-core/src/features/events/HandlerConfig.ts @@ -3,20 +3,33 @@ import type { Transport } from "./Transport.js"; import type { HandlerSetup } from "./types.js"; /** - * Internal plumbing: the `createHandler` options, exposed as a DI value so the default - * {@link RootContainerFactory} / {@link ChildContainerFactory} implementations can resolve them - * (and stay decoratable). Not part of the public handler API — callers configure via - * `createHandler` options, not by resolving this. + * Configuration for `createHandler` — and the DI value the default lifecycle abstractions + * ({@link RootContainerFactory} / {@link ChildContainerFactory}) resolve. The object passed to + * `createHandler` 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: Transport; /** - * A pre-built, already root-initialized container. When set, the root is NOT built again — the - * Node server uses this to build the root eagerly at startup (WebSockets upgrade wiring). + * 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. */ - rootContainer: Container | null; + 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; + /** + * Decorate the DI-native handler app before its first use. Runs against the APP container (the + * small container holding {@link HandlerRuntime}, {@link RootContainerFactory} and + * {@link ChildContainerFactory}), 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"); diff --git a/packages/event-handler-core/src/features/events/createHandler.ts b/packages/event-handler-core/src/features/events/createHandler.ts index 3cb8427b46b..2226fa64c76 100644 --- a/packages/event-handler-core/src/features/events/createHandler.ts +++ b/packages/event-handler-core/src/features/events/createHandler.ts @@ -3,33 +3,6 @@ import { HandlerConfig } from "./HandlerConfig.js"; import { HandlerRuntime, DefaultHandlerRuntime } from "./HandlerRuntime.js"; import { DefaultRootContainerFactory } from "./RootContainerFactory.js"; import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; -import { noopTransport } from "./Transport.js"; -import type { Transport } from "./Transport.js"; -import type { HandlerSetup } from "./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; - /** - * Decorate the DI-native handler app before its first use. Runs against the APP container (the - * small container holding {@link HandlerRuntime}, {@link RootContainerFactory} and - * {@link ChildContainerFactory}), so callers can `registerDecorator(...)` around any lifecycle - * step — e.g. wrapping `ChildContainerFactory` to refresh a license before each request. - */ - app?: (container: Container) => void; -} /** * Wires the DI-native handler app and returns the platform-invocable handler. @@ -40,15 +13,11 @@ export interface CreateHandlerOptions { * decoratable abstraction, so transports/composition layers extend the lifecycle without this * function growing new branches. */ -export function createHandler(options: CreateHandlerOptions) { +export function createHandler(config: HandlerConfig.Interface) { const appContainer = new Container(); - appContainer.registerInstance(HandlerConfig, { - root: options.root, - request: options.request, - transport: options.transport ?? noopTransport, - rootContainer: options.rootContainer ?? null - }); + // 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. @@ -57,7 +26,7 @@ export function createHandler(options: CreateHandlerOptions) { appContainer.register(DefaultHandlerRuntime).inSingletonScope(); // Seam: let callers decorate the runtime / factories before the app is resolved. - options.app?.(appContainer); + config.app?.(appContainer); const runtime = appContainer.resolve(HandlerRuntime); From b84976370d41d04cc72b93b9ea658e3c09abf498 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 15:47:48 +0200 Subject: [PATCH 03/16] refactor: replace createHandler with HandlerRuntime.init (ProjectSdk-style) Mirror the ProjectSdk entrypoint pattern: the handler is now a class with a static `init(config)` factory that builds the app container and returns a runtime whose `handle(...)` is the platform-invocable. Folds the former HandlerRuntime DI abstraction into the class and removes the `createHandler` function. Root/ChildContainerFactory remain the decoratable DI seams. Updated callers (createLambdaHandler, createServerHandler, createTestHttpHandler) and tests to `HandlerRuntime.init(...).handle(...)`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 6 +- .../__tests__/EventType.test.ts | 16 ++--- .../__tests__/HandlerRuntime.test.ts | 18 ++--- .../__tests__/RequestInitializer.test.ts | 10 +-- .../src/features/events/HandlerRuntime.ts | 65 +++++++++++-------- .../src/features/events/createHandler.ts | 34 ---------- .../src/features/events/index.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 6 +- .../src/createServerHandler.ts | 6 +- 9 files changed, 70 insertions(+), 93 deletions(-) delete mode 100644 packages/event-handler-core/src/features/events/createHandler.ts diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 1b25c6eeee4..ad73d914c48 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 { HandlerRuntime } 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 runtime = HandlerRuntime.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => handle(event, context); + return (event: any, context?: Context): Promise => runtime.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..dd5eaa91b31 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.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 runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await invoke({ + const result = await runtime.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { container.register(httpType); } }); - await expect(invoke({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( + await expect(runtime.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 runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await invoke({ + await runtime.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await invoke({ Records: [{}] })).toBe("other"); + expect(await runtime.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/HandlerRuntime.test.ts b/packages/event-handler-core/__tests__/HandlerRuntime.test.ts index 438811ab5fa..e3fbb2d40cb 100644 --- a/packages/event-handler-core/__tests__/HandlerRuntime.test.ts +++ b/packages/event-handler-core/__tests__/HandlerRuntime.test.ts @@ -5,7 +5,7 @@ import type { IEventType } from "~/features/events/EventType.js"; import { ChildContainerFactory } from "~/features/events/ChildContainerFactory.js"; import { RootContainerFactory } from "~/features/events/RootContainerFactory.js"; import type { IEventHandler } from "~/features/events/EventHandler.js"; -import { createHandler } from "~/features/events/createHandler.js"; +import { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; describe("HandlerRuntime (DI-native handler app)", () => { class HttpEventType implements IEventType { @@ -34,14 +34,14 @@ describe("HandlerRuntime (DI-native handler app)", () => { }; it("dispatches through the runtime like the previous closure", async () => { - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await invoke(httpEvent)).toBe("ok"); + expect(await runtime.handle(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -62,7 +62,7 @@ describe("HandlerRuntime (DI-native handler app)", () => { dependencies: [] }); - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -72,8 +72,8 @@ describe("HandlerRuntime (DI-native handler app)", () => { } }); - expect(await invoke(httpEvent)).toBe("ok"); - expect(await invoke(httpEvent)).toBe("ok"); + expect(await runtime.handle(httpEvent)).toBe("ok"); + expect(await runtime.handle(httpEvent)).toBe("ok"); // Decorator wraps create() once per request (before + after), twice over two invocations. expect(calls).toEqual(["before", "after", "before", "after"]); @@ -97,7 +97,7 @@ describe("HandlerRuntime (DI-native handler app)", () => { dependencies: [] }); - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -108,8 +108,8 @@ describe("HandlerRuntime (DI-native handler app)", () => { } }); - await invoke(httpEvent); - await invoke(httpEvent); + await runtime.handle(httpEvent); + await runtime.handle(httpEvent); // get() is called per request, but the underlying root is built (root setup runs) only once. expect(rootBuilds).toBe(2); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index eb4ce1f6396..3b1325f91f0 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await invoke(httpEvent); + await runtime.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const invoke = createHandler({ + const runtime = HandlerRuntime.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await invoke(httpEvent)).toBe("ok"); + expect(await runtime.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/HandlerRuntime.ts b/packages/event-handler-core/src/features/events/HandlerRuntime.ts index 8108be5a415..018dc3e0cd2 100644 --- a/packages/event-handler-core/src/features/events/HandlerRuntime.ts +++ b/packages/event-handler-core/src/features/events/HandlerRuntime.ts @@ -1,35 +1,51 @@ -import { Abstraction } from "@webiny/di"; +import { Container } from "@webiny/di"; import { EventType } from "./EventType.js"; -import { RootContainerFactory } from "./RootContainerFactory.js"; -import { ChildContainerFactory } from "./ChildContainerFactory.js"; +import { HandlerConfig } from "./HandlerConfig.js"; +import { RootContainerFactory, DefaultRootContainerFactory } from "./RootContainerFactory.js"; +import { ChildContainerFactory, DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** - * The DI-native handler app: the top-level orchestrator returned (indirectly) by `createHandler`. - * It owns the whole per-invocation flow — obtain the root container, create the per-request child, - * match the incoming event to its {@link EventType}, and dispatch through the handler chain. + * The DI-native handler app. `HandlerRuntime.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 a runtime whose `handle()` is the + * platform-invocable handler. * - * Decoratable (distinct from {@link EventHandler}, which is a single handler IN the dispatch chain). - * The root and child container steps are delegated to the {@link RootContainerFactory} / - * {@link ChildContainerFactory} abstractions so each is independently decoratable. + * 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. `HandlerRuntime` is distinct from {@link EventHandler}, which is a single + * handler IN the dispatch chain. */ -export interface IHandlerRuntime { - handle(rawArgs: any[]): Promise; -} - -export const HandlerRuntime = new Abstraction("HandlerRuntime"); - -export namespace HandlerRuntime { - export type Interface = IHandlerRuntime; -} - -class HandlerRuntimeImpl implements IHandlerRuntime { - constructor( +export class HandlerRuntime { + private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - async handle(rawArgs: any[]): Promise { + static init(config: HandlerConfig.Interface): HandlerRuntime { + 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 HandlerRuntime( + appContainer.resolve(RootContainerFactory), + appContainer.resolve(ChildContainerFactory) + ); + } + + async handle(...rawArgs: any[]): Promise { const root = await this.rootContainerFactory.get(); const child = await this.childContainerFactory.create(root, rawArgs); @@ -63,8 +79,3 @@ class HandlerRuntimeImpl implements IHandlerRuntime { return executeChain(handlers, event); } } - -export const DefaultHandlerRuntime = HandlerRuntime.createImplementation({ - implementation: HandlerRuntimeImpl, - dependencies: [RootContainerFactory, ChildContainerFactory] -}); 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 2226fa64c76..00000000000 --- a/packages/event-handler-core/src/features/events/createHandler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Container } from "@webiny/di"; -import { HandlerConfig } from "./HandlerConfig.js"; -import { HandlerRuntime, DefaultHandlerRuntime } from "./HandlerRuntime.js"; -import { DefaultRootContainerFactory } from "./RootContainerFactory.js"; -import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; - -/** - * Wires the DI-native handler app and returns the platform-invocable handler. - * - * The handler is itself a small DI app living in an "app container" (distinct from the per-process - * root container and the per-request child container it goes on to create). `HandlerRuntime` owns - * the flow; `RootContainerFactory` and `ChildContainerFactory` own container creation. Each is a - * decoratable abstraction, so transports/composition layers extend the lifecycle without this - * function growing new branches. - */ -export function createHandler(config: HandlerConfig.Interface) { - 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(); - appContainer.register(DefaultHandlerRuntime).inSingletonScope(); - - // Seam: let callers decorate the runtime / factories before the app is resolved. - config.app?.(appContainer); - - const runtime = appContainer.resolve(HandlerRuntime); - - return (...rawArgs: any[]): Promise => runtime.handle(rawArgs); -} diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index f8256bd890d..1376c4ad350 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -4,7 +4,7 @@ export * from "./RequestContainer.js"; export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; -export * from "./createHandler.js"; +export * from "./HandlerConfig.js"; export * from "./HandlerRuntime.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.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..ecb95152754 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.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 runtime = HandlerRuntime.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 runtime.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..be5d52835bd 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 { HandlerRuntime } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const handle = createHandler({ + const runtime = HandlerRuntime.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await handle(req)) as IHttpResponse; + const response = (await runtime.handle(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; if (body === undefined || body === null) { From eaa5d6389a85eff8b4f7d268aab3c1dee53f9d30 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 16:08:16 +0200 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20rename=20HandlerRuntime=20?= =?UTF-8?q?=E2=86=92=20EventDispatcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-level orchestrator's primary job is dispatch: match the incoming event to its EventType and route it to that type's handler chain. Renamed to EventDispatcher (aligns with the existing "dispatch" vocabulary; no clash with the EventHandler leaf). Entry point is `EventDispatcher.init(config).handle(...)`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- packages/event-handler-aws/src/createLambdaHandler.ts | 4 ++-- ...{HandlerRuntime.test.ts => EventDispatcher.test.ts} | 10 +++++----- .../event-handler-core/__tests__/EventType.test.ts | 8 ++++---- .../__tests__/RequestInitializer.test.ts | 6 +++--- .../events/{HandlerRuntime.ts => EventDispatcher.ts} | 10 +++++----- .../src/features/events/HandlerConfig.ts | 2 +- .../event-handler-core/src/features/events/index.ts | 2 +- .../src/features/testing/createTestHttpHandler.ts | 4 ++-- .../event-handler-server/src/createServerHandler.ts | 4 ++-- 9 files changed, 25 insertions(+), 25 deletions(-) rename packages/event-handler-core/__tests__/{HandlerRuntime.test.ts => EventDispatcher.test.ts} (93%) rename packages/event-handler-core/src/features/events/{HandlerRuntime.ts => EventDispatcher.ts} (91%) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index ad73d914c48..92c65d5ce80 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { HandlerRuntime } from "@webiny/event-handler-core"; +import { EventDispatcher } 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,7 +14,7 @@ export interface CreateLambdaHandlerOptions { * lives in {@link awsLambdaTransport}; everything else is the shared handler loop. */ export function createLambdaHandler(options: CreateLambdaHandlerOptions) { - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: options.root, request: options.request, transport: awsLambdaTransport diff --git a/packages/event-handler-core/__tests__/HandlerRuntime.test.ts b/packages/event-handler-core/__tests__/EventDispatcher.test.ts similarity index 93% rename from packages/event-handler-core/__tests__/HandlerRuntime.test.ts rename to packages/event-handler-core/__tests__/EventDispatcher.test.ts index e3fbb2d40cb..fe0cdef466d 100644 --- a/packages/event-handler-core/__tests__/HandlerRuntime.test.ts +++ b/packages/event-handler-core/__tests__/EventDispatcher.test.ts @@ -5,9 +5,9 @@ import type { IEventType } from "~/features/events/EventType.js"; import { ChildContainerFactory } from "~/features/events/ChildContainerFactory.js"; import { RootContainerFactory } from "~/features/events/RootContainerFactory.js"; import type { IEventHandler } from "~/features/events/EventHandler.js"; -import { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; -describe("HandlerRuntime (DI-native handler app)", () => { +describe("EventDispatcher (DI-native handler app)", () => { class HttpEventType implements IEventType { canHandle(e: any): e is any { return !!e.method; @@ -34,7 +34,7 @@ describe("HandlerRuntime (DI-native handler app)", () => { }; it("dispatches through the runtime like the previous closure", async () => { - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -62,7 +62,7 @@ describe("HandlerRuntime (DI-native handler app)", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -97,7 +97,7 @@ describe("HandlerRuntime (DI-native handler app)", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { rootSetupCalls++; container.register(httpType); diff --git a/packages/event-handler-core/__tests__/EventType.test.ts b/packages/event-handler-core/__tests__/EventType.test.ts index dd5eaa91b31..a6988c10e7e 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; describe("EventType dispatch", () => { it("should route to correct handler based on canHandle", async () => { @@ -30,7 +30,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); @@ -62,7 +62,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); } @@ -123,7 +123,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(otherType); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index 3b1325f91f0..71e93362758 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); @@ -95,7 +95,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); diff --git a/packages/event-handler-core/src/features/events/HandlerRuntime.ts b/packages/event-handler-core/src/features/events/EventDispatcher.ts similarity index 91% rename from packages/event-handler-core/src/features/events/HandlerRuntime.ts rename to packages/event-handler-core/src/features/events/EventDispatcher.ts index 018dc3e0cd2..165ade48c62 100644 --- a/packages/event-handler-core/src/features/events/HandlerRuntime.ts +++ b/packages/event-handler-core/src/features/events/EventDispatcher.ts @@ -6,7 +6,7 @@ import { ChildContainerFactory, DefaultChildContainerFactory } from "./ChildCont import { executeChain } from "./chain.js"; /** - * The DI-native handler app. `HandlerRuntime.init(config)` builds a small "app container" (distinct + * The DI-native handler app. `EventDispatcher.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 a runtime whose `handle()` is the * platform-invocable handler. @@ -14,16 +14,16 @@ import { executeChain } from "./chain.js"; * 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. `HandlerRuntime` is distinct from {@link EventHandler}, which is a single + * growing new branches. `EventDispatcher` is distinct from {@link EventHandler}, which is a single * handler IN the dispatch chain. */ -export class HandlerRuntime { +export class EventDispatcher { private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - static init(config: HandlerConfig.Interface): HandlerRuntime { + static init(config: HandlerConfig.Interface): EventDispatcher { const appContainer = new Container(); // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. @@ -39,7 +39,7 @@ export class HandlerRuntime { // Resolve the factories once (decorators applied) so their state — notably the memoized // root — is reused across every invocation of handle(). - return new HandlerRuntime( + return new EventDispatcher( appContainer.resolve(RootContainerFactory), appContainer.resolve(ChildContainerFactory) ); diff --git a/packages/event-handler-core/src/features/events/HandlerConfig.ts b/packages/event-handler-core/src/features/events/HandlerConfig.ts index 95190709e9e..f81eced54c4 100644 --- a/packages/event-handler-core/src/features/events/HandlerConfig.ts +++ b/packages/event-handler-core/src/features/events/HandlerConfig.ts @@ -25,7 +25,7 @@ export interface IHandlerConfig { rootContainer?: Container; /** * Decorate the DI-native handler app before its first use. Runs against the APP container (the - * small container holding {@link HandlerRuntime}, {@link RootContainerFactory} and + * small container holding {@link EventDispatcher}, {@link RootContainerFactory} and * {@link ChildContainerFactory}), so callers can `registerDecorator(...)` around any lifecycle * step — e.g. wrapping `ChildContainerFactory` to refresh a license before each request. */ diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index 1376c4ad350..d468b5784fc 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,7 +5,7 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./HandlerConfig.js"; -export * from "./HandlerRuntime.js"; +export * from "./EventDispatcher.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index ecb95152754..4b00616ba1b 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 { HandlerRuntime } from "~/features/events/HandlerRuntime.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.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 runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: async container => { container.register(TestHttpEventType); container.register(HttpRouterHandler); diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index be5d52835bd..f95c547a29f 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 { HandlerRuntime } from "@webiny/event-handler-core"; +import { EventDispatcher } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const runtime = HandlerRuntime.init({ + const runtime = EventDispatcher.init({ root: options.root, request: options.request, rootContainer From f61e2037e1dc7e29938dc9bfe358fc29e562519b Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 16:23:18 +0200 Subject: [PATCH 05/16] refactor(event-handler-core): split lifecycle abstractions from default impls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the api-core layout: DI contracts (HandlerConfig, RootContainerFactory, ChildContainerFactory — token + interface + namespace) move to an `abstractions.ts`; the default implementations stay in their own files (DefaultRootContainerFactory, DefaultChildContainerFactory). Consumers import the contract from ./abstractions and the default impl from its file. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../__tests__/EventDispatcher.test.ts | 3 +- .../features/events/ChildContainerFactory.ts | 26 +------ .../src/features/events/EventDispatcher.ts | 6 +- .../src/features/events/HandlerConfig.ts | 39 ---------- .../features/events/RootContainerFactory.ts | 23 +----- .../src/features/events/abstractions.ts | 76 +++++++++++++++++++ .../src/features/events/index.ts | 2 +- 7 files changed, 87 insertions(+), 88 deletions(-) delete mode 100644 packages/event-handler-core/src/features/events/HandlerConfig.ts create mode 100644 packages/event-handler-core/src/features/events/abstractions.ts diff --git a/packages/event-handler-core/__tests__/EventDispatcher.test.ts b/packages/event-handler-core/__tests__/EventDispatcher.test.ts index fe0cdef466d..64f7d0c8bea 100644 --- a/packages/event-handler-core/__tests__/EventDispatcher.test.ts +++ b/packages/event-handler-core/__tests__/EventDispatcher.test.ts @@ -2,8 +2,7 @@ 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 } from "~/features/events/ChildContainerFactory.js"; -import { RootContainerFactory } from "~/features/events/RootContainerFactory.js"; +import { ChildContainerFactory, RootContainerFactory } from "~/features/events/abstractions.js"; import type { IEventHandler } from "~/features/events/EventHandler.js"; import { EventDispatcher } from "~/features/events/EventDispatcher.js"; diff --git a/packages/event-handler-core/src/features/events/ChildContainerFactory.ts b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts index 7265b6c8fe4..4e0c1316381 100644 --- a/packages/event-handler-core/src/features/events/ChildContainerFactory.ts +++ b/packages/event-handler-core/src/features/events/ChildContainerFactory.ts @@ -1,30 +1,10 @@ -import { Abstraction, Container } from "@webiny/di"; -import { HandlerConfig } from "./HandlerConfig.js"; +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"; -/** - * 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; -} - -class ChildContainerFactoryImpl implements IChildContainerFactory { +class ChildContainerFactoryImpl implements ChildContainerFactory.Interface { constructor(private config: HandlerConfig.Interface) {} async create(root: Container, rawArgs: any[]): Promise { diff --git a/packages/event-handler-core/src/features/events/EventDispatcher.ts b/packages/event-handler-core/src/features/events/EventDispatcher.ts index 165ade48c62..d05d4f3a3e0 100644 --- a/packages/event-handler-core/src/features/events/EventDispatcher.ts +++ b/packages/event-handler-core/src/features/events/EventDispatcher.ts @@ -1,8 +1,8 @@ import { Container } from "@webiny/di"; import { EventType } from "./EventType.js"; -import { HandlerConfig } from "./HandlerConfig.js"; -import { RootContainerFactory, DefaultRootContainerFactory } from "./RootContainerFactory.js"; -import { ChildContainerFactory, DefaultChildContainerFactory } from "./ChildContainerFactory.js"; +import { HandlerConfig, RootContainerFactory, ChildContainerFactory } from "./abstractions.js"; +import { DefaultRootContainerFactory } from "./RootContainerFactory.js"; +import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** diff --git a/packages/event-handler-core/src/features/events/HandlerConfig.ts b/packages/event-handler-core/src/features/events/HandlerConfig.ts deleted file mode 100644 index f81eced54c4..00000000000 --- a/packages/event-handler-core/src/features/events/HandlerConfig.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Abstraction, Container } from "@webiny/di"; -import type { Transport } from "./Transport.js"; -import type { HandlerSetup } from "./types.js"; - -/** - * Configuration for `createHandler` — and the DI value the default lifecycle abstractions - * ({@link RootContainerFactory} / {@link ChildContainerFactory}) resolve. The object passed to - * `createHandler` 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; - /** - * 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; - /** - * Decorate the DI-native handler app before its first use. Runs against the APP container (the - * small container holding {@link EventDispatcher}, {@link RootContainerFactory} and - * {@link ChildContainerFactory}), 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; -} diff --git a/packages/event-handler-core/src/features/events/RootContainerFactory.ts b/packages/event-handler-core/src/features/events/RootContainerFactory.ts index a37f3c18ebd..2ba01fe88f4 100644 --- a/packages/event-handler-core/src/features/events/RootContainerFactory.ts +++ b/packages/event-handler-core/src/features/events/RootContainerFactory.ts @@ -1,24 +1,7 @@ -import { Abstraction, Container } from "@webiny/di"; -import { HandlerConfig } from "./HandlerConfig.js"; +import { Container } from "@webiny/di"; +import { HandlerConfig, RootContainerFactory } from "./abstractions.js"; -/** - * 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. - * - * When {@link HandlerConfig.rootContainer} is supplied (the Node server builds the root eagerly at - * startup), that container is returned as-is and `config.root` is NOT called again. - */ -export interface IRootContainerFactory { - get(): Promise; -} - -export const RootContainerFactory = new Abstraction("RootContainerFactory"); - -export namespace RootContainerFactory { - export type Interface = IRootContainerFactory; -} - -class RootContainerFactoryImpl implements IRootContainerFactory { +class RootContainerFactoryImpl implements RootContainerFactory.Interface { private rootContainer: Container | null; constructor(private config: HandlerConfig.Interface) { 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..0812399b1ef --- /dev/null +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -0,0 +1,76 @@ +import { Abstraction, Container } from "@webiny/di"; +import type { Transport } from "./Transport.js"; +import type { HandlerSetup } from "./types.js"; + +/** + * Configuration for `EventDispatcher.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; + /** + * 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; + /** + * 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. + * + * When {@link HandlerConfig.rootContainer} is supplied (the Node server builds the root eagerly at + * startup), that container is returned as-is and `config.root` is NOT called again. + */ +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/index.ts b/packages/event-handler-core/src/features/events/index.ts index d468b5784fc..a76e9f8f052 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -4,7 +4,7 @@ export * from "./RequestContainer.js"; export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; -export * from "./HandlerConfig.js"; +export * from "./abstractions.js"; export * from "./EventDispatcher.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; From 1344376485d2fb7fa7d5f5e358e89c8ad957ce51 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 16:23:31 +0200 Subject: [PATCH 06/16] docs(plans): sync handler app name to EventDispatcher Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- plans/licensing-feature-flags.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index 9a397f23dcc..b1ad052d7f4 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -24,26 +24,26 @@ Durable across all phases: - **`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):** `HandlerRuntime` (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). +- **Handler abstractions (new):** `EventDispatcher` (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: `HandlerRuntime` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. +**Goal:** replace the `createHandler` closure with a DI-native app: `EventDispatcher` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. ### What to build -- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `HandlerRuntime`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. -- `createHandler` becomes thin: build app container → register defaults → `resolve(HandlerRuntime)` → return `(...rawArgs) => runtime.handle(rawArgs)`. +- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `EventDispatcher`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. +- `createHandler` becomes thin: build app container → register defaults → `resolve(EventDispatcher)` → 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. -- `HandlerRuntime.handle(rawArgs)` — orchestrates: `root = rootContainerFactory.get()`; `child = childContainerFactory.create(root, rawArgs)`; event-type match; `executeChain`. Decoratable. +- `EventDispatcher.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 `HandlerRuntime`; the returned invocable behaves identically to today. +- [ ] `createHandler` builds an app container and resolves `EventDispatcher`; 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). From fe54799840d2851d816ce9ef3143bfdc20d298eb Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 16:47:05 +0200 Subject: [PATCH 07/16] =?UTF-8?q?refactor:=20rename=20runtime=20var=20?= =?UTF-8?q?=E2=86=92=20dispatcher=20(EventDispatcher=20leftover)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 4 ++-- .../__tests__/EventDispatcher.test.ts | 18 +++++++++--------- .../__tests__/EventType.test.ts | 14 +++++++------- .../__tests__/RequestInitializer.test.ts | 8 ++++---- .../src/features/events/EventDispatcher.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 4 ++-- .../src/createServerHandler.ts | 4 ++-- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 92c65d5ce80..90b0aaf9422 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -14,11 +14,11 @@ export interface CreateLambdaHandlerOptions { * lives in {@link awsLambdaTransport}; everything else is the shared handler loop. */ export function createLambdaHandler(options: CreateLambdaHandlerOptions) { - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => runtime.handle(event, context); + return (event: any, context?: Context): Promise => dispatcher.handle(event, context); } diff --git a/packages/event-handler-core/__tests__/EventDispatcher.test.ts b/packages/event-handler-core/__tests__/EventDispatcher.test.ts index 64f7d0c8bea..f0264d26597 100644 --- a/packages/event-handler-core/__tests__/EventDispatcher.test.ts +++ b/packages/event-handler-core/__tests__/EventDispatcher.test.ts @@ -32,15 +32,15 @@ describe("EventDispatcher (DI-native handler app)", () => { return EventHandler.createImplementation({ implementation: OkHandler, dependencies: [] }); }; - it("dispatches through the runtime like the previous closure", async () => { - const runtime = EventDispatcher.init({ + it("dispatches an event like the previous closure", async () => { + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await runtime.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -61,7 +61,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -71,8 +71,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - expect(await runtime.handle(httpEvent)).toBe("ok"); - expect(await runtime.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); // Decorator wraps create() once per request (before + after), twice over two invocations. expect(calls).toEqual(["before", "after", "before", "after"]); @@ -96,7 +96,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -107,8 +107,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - await runtime.handle(httpEvent); - await runtime.handle(httpEvent); + await dispatcher.handle(httpEvent); + await dispatcher.handle(httpEvent); // get() is called per request, but the underlying root is built (root setup runs) only once. expect(rootBuilds).toBe(2); diff --git a/packages/event-handler-core/__tests__/EventType.test.ts b/packages/event-handler-core/__tests__/EventType.test.ts index a6988c10e7e..215840f32f0 100644 --- a/packages/event-handler-core/__tests__/EventType.test.ts +++ b/packages/event-handler-core/__tests__/EventType.test.ts @@ -30,14 +30,14 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await runtime.handle({ + const result = await dispatcher.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); } }); - await expect(runtime.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( + await expect(dispatcher.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( "No event type matched the incoming event" ); }); @@ -123,7 +123,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await runtime.handle({ + await dispatcher.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await runtime.handle({ Records: [{}] })).toBe("other"); + expect(await dispatcher.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index 71e93362758..ec8315c815c 100644 --- a/packages/event-handler-core/__tests__/RequestInitializer.test.ts +++ b/packages/event-handler-core/__tests__/RequestInitializer.test.ts @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await runtime.handle(httpEvent); + await dispatcher.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await runtime.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/EventDispatcher.ts b/packages/event-handler-core/src/features/events/EventDispatcher.ts index d05d4f3a3e0..4b0c34db046 100644 --- a/packages/event-handler-core/src/features/events/EventDispatcher.ts +++ b/packages/event-handler-core/src/features/events/EventDispatcher.ts @@ -8,7 +8,7 @@ import { executeChain } from "./chain.js"; /** * The DI-native handler app. `EventDispatcher.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 a runtime whose `handle()` is the + * wires the default lifecycle abstractions, and returns a dispatcher whose `handle()` is the * platform-invocable handler. * * The lifecycle is delegated to decoratable DI abstractions — {@link RootContainerFactory} (build diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index 4b00616ba1b..021924d6ea6 100644 --- a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts +++ b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts @@ -15,7 +15,7 @@ export interface createTestHttpHandlerOptions { * Callers layer middleware on top via container.registerDecorator() in options.root. */ export function createTestHttpHandler(options: createTestHttpHandlerOptions) { - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.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 runtime.handle({ + return dispatcher.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 f95c547a29f..3c2c8663e9d 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const runtime = EventDispatcher.init({ + const dispatcher = EventDispatcher.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await runtime.handle(req)) as IHttpResponse; + const response = (await dispatcher.handle(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; if (body === undefined || body === null) { From 19ae33c07fc2166ef06257d3f87fe157b693380f Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 16:53:22 +0200 Subject: [PATCH 08/16] =?UTF-8?q?refactor:=20rename=20EventDispatcher=20?= =?UTF-8?q?=E2=86=92=20EventProcessor=20(.init/.process)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final name for the app-level orchestrator: it takes an event and processes it end-to-end (setup containers, route, run the handler chain) and returns the result — captures the return that "dispatcher" undersells, and pairs with the EventHandler leaf (processor orchestrates, handler handles). Method renamed handle → process for cohesion: `EventProcessor.init(config).process(event)`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 6 +++--- ...patcher.test.ts => EventProcessor.test.ts} | 20 +++++++++---------- .../__tests__/EventType.test.ts | 16 +++++++-------- .../__tests__/RequestInitializer.test.ts | 10 +++++----- .../{EventDispatcher.ts => EventProcessor.ts} | 16 +++++++-------- .../src/features/events/abstractions.ts | 2 +- .../src/features/events/index.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 6 +++--- .../src/createServerHandler.ts | 6 +++--- plans/licensing-feature-flags.md | 12 +++++------ 10 files changed, 48 insertions(+), 48 deletions(-) rename packages/event-handler-core/__tests__/{EventDispatcher.test.ts => EventProcessor.test.ts} (86%) rename packages/event-handler-core/src/features/events/{EventDispatcher.ts => EventProcessor.ts} (86%) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 90b0aaf9422..ba35fed309b 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { EventDispatcher } from "@webiny/event-handler-core"; +import { EventProcessor } 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 dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => dispatcher.handle(event, context); + return (event: any, context?: Context): Promise => processor.process(event, context); } diff --git a/packages/event-handler-core/__tests__/EventDispatcher.test.ts b/packages/event-handler-core/__tests__/EventProcessor.test.ts similarity index 86% rename from packages/event-handler-core/__tests__/EventDispatcher.test.ts rename to packages/event-handler-core/__tests__/EventProcessor.test.ts index f0264d26597..d73e5dab6db 100644 --- a/packages/event-handler-core/__tests__/EventDispatcher.test.ts +++ b/packages/event-handler-core/__tests__/EventProcessor.test.ts @@ -4,9 +4,9 @@ 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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { EventProcessor } from "~/features/events/EventProcessor.js"; -describe("EventDispatcher (DI-native handler app)", () => { +describe("EventProcessor (DI-native handler app)", () => { class HttpEventType implements IEventType { canHandle(e: any): e is any { return !!e.method; @@ -33,14 +33,14 @@ describe("EventDispatcher (DI-native handler app)", () => { }; it("dispatches an event like the previous closure", async () => { - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await processor.process(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -61,7 +61,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -71,8 +71,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await processor.process(httpEvent)).toBe("ok"); + expect(await processor.process(httpEvent)).toBe("ok"); // Decorator wraps create() once per request (before + after), twice over two invocations. expect(calls).toEqual(["before", "after", "before", "after"]); @@ -96,7 +96,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -107,8 +107,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - await dispatcher.handle(httpEvent); - await dispatcher.handle(httpEvent); + await processor.process(httpEvent); + await processor.process(httpEvent); // get() is called per request, but the underlying root is built (root setup runs) only once. expect(rootBuilds).toBe(2); diff --git a/packages/event-handler-core/__tests__/EventType.test.ts b/packages/event-handler-core/__tests__/EventType.test.ts index 215840f32f0..980e5923869 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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { EventProcessor } from "~/features/events/EventProcessor.js"; describe("EventType dispatch", () => { it("should route to correct handler based on canHandle", async () => { @@ -30,14 +30,14 @@ describe("EventType dispatch", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await dispatcher.handle({ + const result = await processor.process({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); } }); - await expect(dispatcher.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( + await expect(processor.process({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( "No event type matched the incoming event" ); }); @@ -123,7 +123,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await dispatcher.handle({ + await processor.process({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await dispatcher.handle({ Records: [{}] })).toBe("other"); + expect(await processor.process({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index ec8315c815c..d05a6e362e2 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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { EventProcessor } from "~/features/events/EventProcessor.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await dispatcher.handle(httpEvent); + await processor.process(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await processor.process(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/EventDispatcher.ts b/packages/event-handler-core/src/features/events/EventProcessor.ts similarity index 86% rename from packages/event-handler-core/src/features/events/EventDispatcher.ts rename to packages/event-handler-core/src/features/events/EventProcessor.ts index 4b0c34db046..607810ad744 100644 --- a/packages/event-handler-core/src/features/events/EventDispatcher.ts +++ b/packages/event-handler-core/src/features/events/EventProcessor.ts @@ -6,24 +6,24 @@ import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** - * The DI-native handler app. `EventDispatcher.init(config)` builds a small "app container" (distinct + * The DI-native handler app. `EventProcessor.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 a dispatcher whose `handle()` is the + * wires the default lifecycle abstractions, and returns a processor whose `process()` 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. `EventDispatcher` is distinct from {@link EventHandler}, which is a single + * growing new branches. `EventProcessor` is distinct from {@link EventHandler}, which is a single * handler IN the dispatch chain. */ -export class EventDispatcher { +export class EventProcessor { private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - static init(config: HandlerConfig.Interface): EventDispatcher { + static init(config: HandlerConfig.Interface): EventProcessor { const appContainer = new Container(); // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. @@ -38,14 +38,14 @@ export class EventDispatcher { 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 EventDispatcher( + // root — is reused across every invocation of process(). + return new EventProcessor( appContainer.resolve(RootContainerFactory), appContainer.resolve(ChildContainerFactory) ); } - async handle(...rawArgs: any[]): Promise { + async process(...rawArgs: any[]): Promise { const root = await this.rootContainerFactory.get(); const child = await this.childContainerFactory.create(root, rawArgs); diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts index 0812399b1ef..11f8de3d4c1 100644 --- a/packages/event-handler-core/src/features/events/abstractions.ts +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -3,7 +3,7 @@ import type { Transport } from "./Transport.js"; import type { HandlerSetup } from "./types.js"; /** - * Configuration for `EventDispatcher.init` — and the DI value the default lifecycle abstractions + * Configuration for `EventProcessor.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. diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index a76e9f8f052..b8b8dfddb3c 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,7 +5,7 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./abstractions.js"; -export * from "./EventDispatcher.js"; +export * from "./EventProcessor.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index 021924d6ea6..891fb71d7c7 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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { EventProcessor } from "~/features/events/EventProcessor.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 dispatcher = EventDispatcher.init({ + const processor = EventProcessor.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 dispatcher.handle({ + return processor.process({ 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 3c2c8663e9d..bd83c93821d 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 { EventDispatcher } from "@webiny/event-handler-core"; +import { EventProcessor } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const dispatcher = EventDispatcher.init({ + const processor = EventProcessor.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await dispatcher.handle(req)) as IHttpResponse; + const response = (await processor.process(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; if (body === undefined || body === null) { diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index b1ad052d7f4..768a5c3e4c4 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -24,26 +24,26 @@ Durable across all phases: - **`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):** `EventDispatcher` (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). +- **Handler abstractions (new):** `EventProcessor` (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: `EventDispatcher` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. +**Goal:** replace the `createHandler` closure with a DI-native app: `EventProcessor` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. ### What to build -- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `EventDispatcher`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. -- `createHandler` becomes thin: build app container → register defaults → `resolve(EventDispatcher)` → return `(...rawArgs) => runtime.handle(rawArgs)`. +- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `EventProcessor`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. +- `createHandler` becomes thin: build app container → register defaults → `resolve(EventProcessor)` → 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. -- `EventDispatcher.handle(rawArgs)` — orchestrates: `root = rootContainerFactory.get()`; `child = childContainerFactory.create(root, rawArgs)`; event-type match; `executeChain`. Decoratable. +- `EventProcessor.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 `EventDispatcher`; the returned invocable behaves identically to today. +- [ ] `createHandler` builds an app container and resolves `EventProcessor`; 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). From a187eb5d56c38eb55c7c8d0b43a409ae70b4b813 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:00:22 +0200 Subject: [PATCH 09/16] =?UTF-8?q?refactor:=20rename=20EventProcessor=20?= =?UTF-8?q?=E2=86=92=20HandlerApp=20(.init/.handle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final name: the app-level orchestrator is the handler app for this package — HandlerApp.init(config).handle(event). Within event-handler-core "the app" needs no Event prefix, and HandlerApp avoids the look-alike with the EventHandler leaf. Method reverted to handle (natural for an app). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 6 +++--- .../__tests__/EventType.test.ts | 16 +++++++-------- ...ntProcessor.test.ts => HandlerApp.test.ts} | 20 +++++++++---------- .../__tests__/RequestInitializer.test.ts | 10 +++++----- .../{EventProcessor.ts => HandlerApp.ts} | 16 +++++++-------- .../src/features/events/abstractions.ts | 2 +- .../src/features/events/index.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 6 +++--- .../src/createServerHandler.ts | 6 +++--- plans/licensing-feature-flags.md | 12 +++++------ 10 files changed, 48 insertions(+), 48 deletions(-) rename packages/event-handler-core/__tests__/{EventProcessor.test.ts => HandlerApp.test.ts} (86%) rename packages/event-handler-core/src/features/events/{EventProcessor.ts => HandlerApp.ts} (86%) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index ba35fed309b..0e05707ef60 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { EventProcessor } 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 processor = EventProcessor.init({ + const app = HandlerApp.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => processor.process(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 980e5923869..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 { EventProcessor } from "~/features/events/EventProcessor.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 processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await processor.process({ + const result = await app.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); } }); - await expect(processor.process({ 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 processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await processor.process({ + await app.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await processor.process({ Records: [{}] })).toBe("other"); + expect(await app.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/EventProcessor.test.ts b/packages/event-handler-core/__tests__/HandlerApp.test.ts similarity index 86% rename from packages/event-handler-core/__tests__/EventProcessor.test.ts rename to packages/event-handler-core/__tests__/HandlerApp.test.ts index d73e5dab6db..f2fdca13190 100644 --- a/packages/event-handler-core/__tests__/EventProcessor.test.ts +++ b/packages/event-handler-core/__tests__/HandlerApp.test.ts @@ -4,9 +4,9 @@ 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 { EventProcessor } from "~/features/events/EventProcessor.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; -describe("EventProcessor (DI-native handler app)", () => { +describe("HandlerApp (DI-native handler app)", () => { class HttpEventType implements IEventType { canHandle(e: any): e is any { return !!e.method; @@ -33,14 +33,14 @@ describe("EventProcessor (DI-native handler app)", () => { }; it("dispatches an event like the previous closure", async () => { - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await processor.process(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -61,7 +61,7 @@ describe("EventProcessor (DI-native handler app)", () => { dependencies: [] }); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -71,8 +71,8 @@ describe("EventProcessor (DI-native handler app)", () => { } }); - expect(await processor.process(httpEvent)).toBe("ok"); - expect(await processor.process(httpEvent)).toBe("ok"); + 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"]); @@ -96,7 +96,7 @@ describe("EventProcessor (DI-native handler app)", () => { dependencies: [] }); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -107,8 +107,8 @@ describe("EventProcessor (DI-native handler app)", () => { } }); - await processor.process(httpEvent); - await processor.process(httpEvent); + 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); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index d05a6e362e2..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 { EventProcessor } from "~/features/events/EventProcessor.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await processor.process(httpEvent); + await app.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await processor.process(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/EventProcessor.ts b/packages/event-handler-core/src/features/events/HandlerApp.ts similarity index 86% rename from packages/event-handler-core/src/features/events/EventProcessor.ts rename to packages/event-handler-core/src/features/events/HandlerApp.ts index 607810ad744..589b5e30b71 100644 --- a/packages/event-handler-core/src/features/events/EventProcessor.ts +++ b/packages/event-handler-core/src/features/events/HandlerApp.ts @@ -6,24 +6,24 @@ import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** - * The DI-native handler app. `EventProcessor.init(config)` builds a small "app container" (distinct + * 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 a processor whose `process()` is the + * 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. `EventProcessor` is distinct from {@link EventHandler}, which is a single + * growing new branches. `HandlerApp` is distinct from {@link EventHandler}, which is a single * handler IN the dispatch chain. */ -export class EventProcessor { +export class HandlerApp { private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - static init(config: HandlerConfig.Interface): EventProcessor { + static init(config: HandlerConfig.Interface): HandlerApp { const appContainer = new Container(); // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. @@ -38,14 +38,14 @@ export class EventProcessor { config.app?.(appContainer); // Resolve the factories once (decorators applied) so their state — notably the memoized - // root — is reused across every invocation of process(). - return new EventProcessor( + // root — is reused across every invocation of handle(). + return new HandlerApp( appContainer.resolve(RootContainerFactory), appContainer.resolve(ChildContainerFactory) ); } - async process(...rawArgs: any[]): Promise { + async handle(...rawArgs: any[]): Promise { const root = await this.rootContainerFactory.get(); const child = await this.childContainerFactory.create(root, rawArgs); diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts index 11f8de3d4c1..617b39c8a32 100644 --- a/packages/event-handler-core/src/features/events/abstractions.ts +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -3,7 +3,7 @@ import type { Transport } from "./Transport.js"; import type { HandlerSetup } from "./types.js"; /** - * Configuration for `EventProcessor.init` — and the DI value the default lifecycle abstractions + * 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. diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index b8b8dfddb3c..3529cd785b4 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,7 +5,7 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./abstractions.js"; -export * from "./EventProcessor.js"; +export * from "./HandlerApp.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index 891fb71d7c7..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 { EventProcessor } from "~/features/events/EventProcessor.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 processor = EventProcessor.init({ + 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 processor.process({ + 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 bd83c93821d..6567c063443 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 { EventProcessor } from "@webiny/event-handler-core"; +import { HandlerApp } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const processor = EventProcessor.init({ + const app = HandlerApp.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await processor.process(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) { diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index 768a5c3e4c4..40c2894b479 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -24,26 +24,26 @@ Durable across all phases: - **`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):** `EventProcessor` (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). +- **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: `EventProcessor` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. +**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: `EventProcessor`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. -- `createHandler` becomes thin: build app container → register defaults → `resolve(EventProcessor)` → return `(...rawArgs) => runtime.handle(rawArgs)`. +- 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. -- `EventProcessor.handle(rawArgs)` — orchestrates: `root = rootContainerFactory.get()`; `child = childContainerFactory.create(root, rawArgs)`; event-type match; `executeChain`. 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 `EventProcessor`; the returned invocable behaves identically to today. +- [ ] `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). From 3eb581dbd7ceea98e325a27715bc6f143f775764 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:02:50 +0200 Subject: [PATCH 10/16] =?UTF-8?q?refactor:=20rename=20HandlerApp=20?= =?UTF-8?q?=E2=86=92=20EventDispatcher=20(.init/.handle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settle on EventDispatcher for the app-level orchestrator: EventDispatcher.init(config).handle(event). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 6 +++--- ...lerApp.test.ts => EventDispatcher.test.ts} | 20 +++++++++---------- .../__tests__/EventType.test.ts | 16 +++++++-------- .../__tests__/RequestInitializer.test.ts | 10 +++++----- .../{HandlerApp.ts => EventDispatcher.ts} | 12 +++++------ .../src/features/events/abstractions.ts | 2 +- .../src/features/events/index.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 6 +++--- .../src/createServerHandler.ts | 6 +++--- plans/licensing-feature-flags.md | 12 +++++------ 10 files changed, 46 insertions(+), 46 deletions(-) rename packages/event-handler-core/__tests__/{HandlerApp.test.ts => EventDispatcher.test.ts} (86%) rename packages/event-handler-core/src/features/events/{HandlerApp.ts => EventDispatcher.ts} (89%) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 0e05707ef60..90b0aaf9422 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { HandlerApp } from "@webiny/event-handler-core"; +import { EventDispatcher } 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 app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => app.handle(event, context); + return (event: any, context?: Context): Promise => dispatcher.handle(event, context); } diff --git a/packages/event-handler-core/__tests__/HandlerApp.test.ts b/packages/event-handler-core/__tests__/EventDispatcher.test.ts similarity index 86% rename from packages/event-handler-core/__tests__/HandlerApp.test.ts rename to packages/event-handler-core/__tests__/EventDispatcher.test.ts index f2fdca13190..f0264d26597 100644 --- a/packages/event-handler-core/__tests__/HandlerApp.test.ts +++ b/packages/event-handler-core/__tests__/EventDispatcher.test.ts @@ -4,9 +4,9 @@ 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"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; -describe("HandlerApp (DI-native handler app)", () => { +describe("EventDispatcher (DI-native handler app)", () => { class HttpEventType implements IEventType { canHandle(e: any): e is any { return !!e.method; @@ -33,14 +33,14 @@ describe("HandlerApp (DI-native handler app)", () => { }; it("dispatches an event like the previous closure", async () => { - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await app.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -61,7 +61,7 @@ describe("HandlerApp (DI-native handler app)", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -71,8 +71,8 @@ describe("HandlerApp (DI-native handler app)", () => { } }); - expect(await app.handle(httpEvent)).toBe("ok"); - expect(await app.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); // Decorator wraps create() once per request (before + after), twice over two invocations. expect(calls).toEqual(["before", "after", "before", "after"]); @@ -96,7 +96,7 @@ describe("HandlerApp (DI-native handler app)", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -107,8 +107,8 @@ describe("HandlerApp (DI-native handler app)", () => { } }); - await app.handle(httpEvent); - await app.handle(httpEvent); + await dispatcher.handle(httpEvent); + await dispatcher.handle(httpEvent); // get() is called per request, but the underlying root is built (root setup runs) only once. expect(rootBuilds).toBe(2); diff --git a/packages/event-handler-core/__tests__/EventType.test.ts b/packages/event-handler-core/__tests__/EventType.test.ts index 91bb15c0848..215840f32f0 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 { HandlerApp } from "~/features/events/HandlerApp.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; describe("EventType dispatch", () => { it("should route to correct handler based on canHandle", async () => { @@ -30,14 +30,14 @@ describe("EventType dispatch", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await app.handle({ + const result = await dispatcher.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); } }); - await expect(app.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( + await expect(dispatcher.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow( "No event type matched the incoming event" ); }); @@ -123,7 +123,7 @@ describe("EventType dispatch", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await app.handle({ + await dispatcher.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await app.handle({ Records: [{}] })).toBe("other"); + expect(await dispatcher.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index f57ba9816cf..ec8315c815c 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 { HandlerApp } from "~/features/events/HandlerApp.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await app.handle(httpEvent); + await dispatcher.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await app.handle(httpEvent)).toBe("ok"); + expect(await dispatcher.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/HandlerApp.ts b/packages/event-handler-core/src/features/events/EventDispatcher.ts similarity index 89% rename from packages/event-handler-core/src/features/events/HandlerApp.ts rename to packages/event-handler-core/src/features/events/EventDispatcher.ts index 589b5e30b71..4b0c34db046 100644 --- a/packages/event-handler-core/src/features/events/HandlerApp.ts +++ b/packages/event-handler-core/src/features/events/EventDispatcher.ts @@ -6,24 +6,24 @@ import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** - * The DI-native handler app. `HandlerApp.init(config)` builds a small "app container" (distinct + * The DI-native handler app. `EventDispatcher.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 + * wires the default lifecycle abstractions, and returns a dispatcher 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 + * growing new branches. `EventDispatcher` is distinct from {@link EventHandler}, which is a single * handler IN the dispatch chain. */ -export class HandlerApp { +export class EventDispatcher { private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - static init(config: HandlerConfig.Interface): HandlerApp { + static init(config: HandlerConfig.Interface): EventDispatcher { const appContainer = new Container(); // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. @@ -39,7 +39,7 @@ export class HandlerApp { // Resolve the factories once (decorators applied) so their state — notably the memoized // root — is reused across every invocation of handle(). - return new HandlerApp( + return new EventDispatcher( appContainer.resolve(RootContainerFactory), appContainer.resolve(ChildContainerFactory) ); diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts index 617b39c8a32..0812399b1ef 100644 --- a/packages/event-handler-core/src/features/events/abstractions.ts +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -3,7 +3,7 @@ import type { Transport } from "./Transport.js"; import type { HandlerSetup } from "./types.js"; /** - * Configuration for `HandlerApp.init` — and the DI value the default lifecycle abstractions + * Configuration for `EventDispatcher.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. diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index 3529cd785b4..a76e9f8f052 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,7 +5,7 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./abstractions.js"; -export * from "./HandlerApp.js"; +export * from "./EventDispatcher.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index e42cd7d5739..021924d6ea6 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 { HandlerApp } from "~/features/events/HandlerApp.js"; +import { EventDispatcher } from "~/features/events/EventDispatcher.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 app = HandlerApp.init({ + const dispatcher = EventDispatcher.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 app.handle({ + return dispatcher.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 6567c063443..3c2c8663e9d 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 { HandlerApp } from "@webiny/event-handler-core"; +import { EventDispatcher } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const app = HandlerApp.init({ + const dispatcher = EventDispatcher.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await app.handle(req)) as IHttpResponse; + const response = (await dispatcher.handle(req)) as IHttpResponse; res.writeHead(response.statusCode, response.headers); const { body } = response; if (body === undefined || body === null) { diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index 40c2894b479..b1ad052d7f4 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -24,26 +24,26 @@ Durable across all phases: - **`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). +- **Handler abstractions (new):** `EventDispatcher` (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. +**Goal:** replace the `createHandler` closure with a DI-native app: `EventDispatcher` + `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)`. +- An **app container** built inside `createHandler` (event-handler-core). Register defaults: `EventDispatcher`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. +- `createHandler` becomes thin: build app container → register defaults → `resolve(EventDispatcher)` → 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. +- `EventDispatcher.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. +- [ ] `createHandler` builds an app container and resolves `EventDispatcher`; 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). From 9e88da2c6290e7dbbaa1adfa73209e2e60fad7e1 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:06:25 +0200 Subject: [PATCH 11/16] =?UTF-8?q?refactor:=20rename=20EventDispatcher=20?= =?UTF-8?q?=E2=86=92=20HandlerApp=20(Express-style=20app.handle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final name: HandlerApp.init(config).handle(event) — used like Express's `const app = ...`. Within event-handler-core "the app" needs no Event prefix and avoids the EventHandler look-alike. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createLambdaHandler.ts | 6 +++--- .../__tests__/EventType.test.ts | 16 +++++++-------- ...tDispatcher.test.ts => HandlerApp.test.ts} | 20 +++++++++---------- .../__tests__/RequestInitializer.test.ts | 10 +++++----- .../{EventDispatcher.ts => HandlerApp.ts} | 12 +++++------ .../src/features/events/abstractions.ts | 2 +- .../src/features/events/index.ts | 2 +- .../features/testing/createTestHttpHandler.ts | 6 +++--- .../src/createServerHandler.ts | 6 +++--- plans/licensing-feature-flags.md | 12 +++++------ 10 files changed, 46 insertions(+), 46 deletions(-) rename packages/event-handler-core/__tests__/{EventDispatcher.test.ts => HandlerApp.test.ts} (86%) rename packages/event-handler-core/src/features/events/{EventDispatcher.ts => HandlerApp.ts} (89%) diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 90b0aaf9422..0e05707ef60 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,4 +1,4 @@ -import { EventDispatcher } 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 dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: options.root, request: options.request, transport: awsLambdaTransport }); - return (event: any, context?: Context): Promise => dispatcher.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 215840f32f0..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 { EventDispatcher } from "~/features/events/EventDispatcher.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 dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - const result = await dispatcher.handle({ + const result = await app.handle({ method: "GET", path: "/test", headers: {}, @@ -62,13 +62,13 @@ describe("EventType dispatch", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); } }); - await expect(dispatcher.handle({ 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 dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(otherType); @@ -133,7 +133,7 @@ describe("EventType dispatch", () => { }); expect( - await dispatcher.handle({ + await app.handle({ method: "GET", path: "/", headers: {}, @@ -142,6 +142,6 @@ describe("EventType dispatch", () => { body: undefined }) ).toBe("http"); - expect(await dispatcher.handle({ Records: [{}] })).toBe("other"); + expect(await app.handle({ Records: [{}] })).toBe("other"); }); }); diff --git a/packages/event-handler-core/__tests__/EventDispatcher.test.ts b/packages/event-handler-core/__tests__/HandlerApp.test.ts similarity index 86% rename from packages/event-handler-core/__tests__/EventDispatcher.test.ts rename to packages/event-handler-core/__tests__/HandlerApp.test.ts index f0264d26597..f2fdca13190 100644 --- a/packages/event-handler-core/__tests__/EventDispatcher.test.ts +++ b/packages/event-handler-core/__tests__/HandlerApp.test.ts @@ -4,9 +4,9 @@ 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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; -describe("EventDispatcher (DI-native handler app)", () => { +describe("HandlerApp (DI-native handler app)", () => { class HttpEventType implements IEventType { canHandle(e: any): e is any { return !!e.method; @@ -33,14 +33,14 @@ describe("EventDispatcher (DI-native handler app)", () => { }; it("dispatches an event like the previous closure", async () => { - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(okHandler()); } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); }); it("runs a ChildContainerFactory decorator on every request (the seam)", async () => { @@ -61,7 +61,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(okHandler()); @@ -71,8 +71,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + 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"]); @@ -96,7 +96,7 @@ describe("EventDispatcher (DI-native handler app)", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { rootSetupCalls++; container.register(httpType); @@ -107,8 +107,8 @@ describe("EventDispatcher (DI-native handler app)", () => { } }); - await dispatcher.handle(httpEvent); - await dispatcher.handle(httpEvent); + 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); diff --git a/packages/event-handler-core/__tests__/RequestInitializer.test.ts b/packages/event-handler-core/__tests__/RequestInitializer.test.ts index ec8315c815c..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 { EventDispatcher } from "~/features/events/EventDispatcher.js"; +import { HandlerApp } from "~/features/events/HandlerApp.js"; describe("RequestInitializer", () => { class HttpEventType implements IEventType { @@ -69,7 +69,7 @@ describe("RequestInitializer", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); @@ -78,7 +78,7 @@ describe("RequestInitializer", () => { } }); - await dispatcher.handle(httpEvent); + await app.handle(httpEvent); expect(order).toEqual(["a", "b", "handler"]); }); @@ -95,13 +95,13 @@ describe("RequestInitializer", () => { dependencies: [] }); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: container => { container.register(httpType); container.register(handler); } }); - expect(await dispatcher.handle(httpEvent)).toBe("ok"); + expect(await app.handle(httpEvent)).toBe("ok"); }); }); diff --git a/packages/event-handler-core/src/features/events/EventDispatcher.ts b/packages/event-handler-core/src/features/events/HandlerApp.ts similarity index 89% rename from packages/event-handler-core/src/features/events/EventDispatcher.ts rename to packages/event-handler-core/src/features/events/HandlerApp.ts index 4b0c34db046..589b5e30b71 100644 --- a/packages/event-handler-core/src/features/events/EventDispatcher.ts +++ b/packages/event-handler-core/src/features/events/HandlerApp.ts @@ -6,24 +6,24 @@ import { DefaultChildContainerFactory } from "./ChildContainerFactory.js"; import { executeChain } from "./chain.js"; /** - * The DI-native handler app. `EventDispatcher.init(config)` builds a small "app container" (distinct + * 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 a dispatcher whose `handle()` is the + * 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. `EventDispatcher` is distinct from {@link EventHandler}, which is a single + * growing new branches. `HandlerApp` is distinct from {@link EventHandler}, which is a single * handler IN the dispatch chain. */ -export class EventDispatcher { +export class HandlerApp { private constructor( private rootContainerFactory: RootContainerFactory.Interface, private childContainerFactory: ChildContainerFactory.Interface ) {} - static init(config: HandlerConfig.Interface): EventDispatcher { + static init(config: HandlerConfig.Interface): HandlerApp { const appContainer = new Container(); // Register the config as-is — the default lifecycle factories resolve HandlerConfig directly. @@ -39,7 +39,7 @@ export class EventDispatcher { // Resolve the factories once (decorators applied) so their state — notably the memoized // root — is reused across every invocation of handle(). - return new EventDispatcher( + return new HandlerApp( appContainer.resolve(RootContainerFactory), appContainer.resolve(ChildContainerFactory) ); diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts index 0812399b1ef..617b39c8a32 100644 --- a/packages/event-handler-core/src/features/events/abstractions.ts +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -3,7 +3,7 @@ import type { Transport } from "./Transport.js"; import type { HandlerSetup } from "./types.js"; /** - * Configuration for `EventDispatcher.init` — and the DI value the default lifecycle abstractions + * 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. diff --git a/packages/event-handler-core/src/features/events/index.ts b/packages/event-handler-core/src/features/events/index.ts index a76e9f8f052..3529cd785b4 100644 --- a/packages/event-handler-core/src/features/events/index.ts +++ b/packages/event-handler-core/src/features/events/index.ts @@ -5,7 +5,7 @@ export * from "./RequestContextInitializer.js"; export * from "./RequestInitializer.js"; export * from "./chain.js"; export * from "./abstractions.js"; -export * from "./EventDispatcher.js"; +export * from "./HandlerApp.js"; export * from "./RootContainerFactory.js"; export * from "./ChildContainerFactory.js"; export * from "./Transport.js"; diff --git a/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts b/packages/event-handler-core/src/features/testing/createTestHttpHandler.ts index 021924d6ea6..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 { EventDispatcher } from "~/features/events/EventDispatcher.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 dispatcher = EventDispatcher.init({ + 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 dispatcher.handle({ + 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 3c2c8663e9d..6567c063443 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 { EventDispatcher } from "@webiny/event-handler-core"; +import { HandlerApp } from "@webiny/event-handler-core"; import type { HandlerSetup, IHttpResponse } from "@webiny/event-handler-core"; export interface CreateServerHandlerOptions { @@ -22,7 +22,7 @@ export async function createServerHandler( const rootContainer = new Container(); await options.root(rootContainer); - const dispatcher = EventDispatcher.init({ + const app = HandlerApp.init({ root: options.root, request: options.request, rootContainer @@ -30,7 +30,7 @@ export async function createServerHandler( const server = http.createServer(async (req, res) => { try { - const response = (await dispatcher.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) { diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index b1ad052d7f4..40c2894b479 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -24,26 +24,26 @@ Durable across all phases: - **`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):** `EventDispatcher` (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). +- **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: `EventDispatcher` + `RootContainerFactory` + `ChildContainerFactory`, all decoratable. No behavior change. +**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: `EventDispatcher`, `RootContainerFactory`, `ChildContainerFactory`. Let callers decorate before first use. -- `createHandler` becomes thin: build app container → register defaults → `resolve(EventDispatcher)` → return `(...rawArgs) => runtime.handle(rawArgs)`. +- 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. -- `EventDispatcher.handle(rawArgs)` — orchestrates: `root = rootContainerFactory.get()`; `child = childContainerFactory.create(root, rawArgs)`; event-type match; `executeChain`. 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 `EventDispatcher`; the returned invocable behaves identically to today. +- [ ] `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). From e6ee23674e58d7725242f5f5996ef13e1c05abb4 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:07:31 +0200 Subject: [PATCH 12/16] docs(plans): note HandlerApp (generic) vs WebinyApiApp (composition) layering Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- plans/licensing-feature-flags.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index 40c2894b479..1af62f3e339 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -110,6 +110,11 @@ Decisions only, recorded in this file: --- + + ## [ ] Phase 5: Delete WcpContext machinery **Goal:** remove the dead request-time WCP path from api-core. From 42a0126e53069578ab305a0b1e2aaacf1851ad9c Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:14:35 +0200 Subject: [PATCH 13/16] refactor: add HandlerApp.getRootContainer; drop rootContainer config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Node server no longer builds the root itself and injects it — it calls `app.getRootContainer()` to get the eagerly-built root for onServer (WebSockets upgrade wiring). HandlerApp now fully owns root creation, so the `rootContainer` HandlerConfig escape hatch is removed (RootContainerFactory builds it lazily, memoized; getRootContainer just triggers that build early). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../__tests__/HandlerApp.test.ts | 20 +++++++++++++++++++ .../src/features/events/HandlerApp.ts | 9 +++++++++ .../features/events/RootContainerFactory.ts | 6 ++---- .../src/features/events/abstractions.ts | 13 +++--------- .../src/createServerHandler.ts | 12 +++++------ 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/event-handler-core/__tests__/HandlerApp.test.ts b/packages/event-handler-core/__tests__/HandlerApp.test.ts index f2fdca13190..2b64ee79baa 100644 --- a/packages/event-handler-core/__tests__/HandlerApp.test.ts +++ b/packages/event-handler-core/__tests__/HandlerApp.test.ts @@ -114,4 +114,24 @@ describe("HandlerApp (DI-native handler app)", () => { 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/src/features/events/HandlerApp.ts b/packages/event-handler-core/src/features/events/HandlerApp.ts index 589b5e30b71..d47bd958dc6 100644 --- a/packages/event-handler-core/src/features/events/HandlerApp.ts +++ b/packages/event-handler-core/src/features/events/HandlerApp.ts @@ -45,6 +45,15 @@ export class HandlerApp { ); } + /** + * 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); diff --git a/packages/event-handler-core/src/features/events/RootContainerFactory.ts b/packages/event-handler-core/src/features/events/RootContainerFactory.ts index 2ba01fe88f4..c49dd164cd8 100644 --- a/packages/event-handler-core/src/features/events/RootContainerFactory.ts +++ b/packages/event-handler-core/src/features/events/RootContainerFactory.ts @@ -2,11 +2,9 @@ import { Container } from "@webiny/di"; import { HandlerConfig, RootContainerFactory } from "./abstractions.js"; class RootContainerFactoryImpl implements RootContainerFactory.Interface { - private rootContainer: Container | null; + private rootContainer: Container | null = null; - constructor(private config: HandlerConfig.Interface) { - this.rootContainer = config.rootContainer ?? null; - } + constructor(private config: HandlerConfig.Interface) {} async get(): Promise { if (!this.rootContainer) { diff --git a/packages/event-handler-core/src/features/events/abstractions.ts b/packages/event-handler-core/src/features/events/abstractions.ts index 617b39c8a32..74d3469ccf4 100644 --- a/packages/event-handler-core/src/features/events/abstractions.ts +++ b/packages/event-handler-core/src/features/events/abstractions.ts @@ -17,12 +17,6 @@ export interface IHandlerConfig { * 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; /** * 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(...)` @@ -40,10 +34,9 @@ export namespace HandlerConfig { /** * 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. - * - * When {@link HandlerConfig.rootContainer} is supplied (the Node server builds the root eagerly at - * startup), that container is returned as-is and `config.root` is NOT called again. + * 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; diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index 6567c063443..7e894b08cbf 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -17,17 +17,15 @@ 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 app = HandlerApp.init({ root: options.root, - request: options.request, - rootContainer + request: options.request }); + // 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(); + const server = http.createServer(async (req, res) => { try { const response = (await app.handle(req)) as IHttpResponse; From 4276437252491bac8472f0b3a7d7294e14c290a4 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 17:17:56 +0200 Subject: [PATCH 14/16] refactor(event-handler-server): build root eagerly only when onServer is set rootContainer is consumed solely inside the onServer block (WebSockets + scheduler startup wiring). Move the getRootContainer() call into that branch so a server with no onServer lets the root build lazily on first request. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- packages/event-handler-server/src/createServerHandler.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index 7e894b08cbf..5ac4d59f28e 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -22,10 +22,6 @@ export async function createServerHandler( request: options.request }); - // 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(); - const server = http.createServer(async (req, res) => { try { const response = (await app.handle(req)) as IHttpResponse; @@ -52,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); } From e44fc707a783e0d256cbd788085492d9bb7aabbd Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 23:16:54 +0200 Subject: [PATCH 15/16] =?UTF-8?q?docs(plans):=20note=20HandlerConfig=20roo?= =?UTF-8?q?t/request=20=E2=86=92=20rootContainer/childContainer=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- plans/licensing-feature-flags.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index 1af62f3e339..a43d016e496 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -51,6 +51,11 @@ Durable across all phases: --- + + ## [ ] Phase 2: Decisions (blocks correctness of Phase 3+) **Goal:** resolve the two open questions that change Phase 3 wiring. From dc92d9f7b30d3ad7a7cee1f15e0724439654044e Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 3 Aug 2026 23:45:39 +0200 Subject: [PATCH 16/16] docs(plans): resolve Phase 2 decisions (no build-side change; add featureFlags query, demote wcp) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- plans/licensing-feature-flags.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/plans/licensing-feature-flags.md b/plans/licensing-feature-flags.md index a43d016e496..bab5ded78da 100644 --- a/plans/licensing-feature-flags.md +++ b/plans/licensing-feature-flags.md @@ -56,21 +56,33 @@ Durable across all phases: up: `root` → `rootContainer`, `request` → `childContainer`. (Aligns with RootContainerFactory / ChildContainerFactory.) Own small PR. --> -## [ ] Phase 2: Decisions (blocks correctness of Phase 3+) +## [x] Phase 2: Decisions (RESOLVED) -**Goal:** resolve the two open questions that change Phase 3 wiring. +**Goal:** resolve the two open questions that change Phase 3/5 wiring. -### What to build +### 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. -Decisions only, recorded in this file: +### Decision 2 — add a dedicated `featureFlags` gql query; demote `wcp` -1. **Build-time `project/GetFeatureFlagsWithLicense`** (bakes `WCP_PROJECT_LICENSE` env into BuildParams) now conflicts with the live runtime refresh — it would bake a stale license. Decide: BuildParams carries **user flags only**; runtime applies the license. Confirm whether the build-time merge is still needed for any consumer (CLI/admin scaffold) or is removed. -2. **`wcp` gql query (`WcpSchemaFactory` in `ApiCoreFeature`)** — admin reads it. Decide: keep and re-back it by the merged `FeatureFlags`, or admin reads flags via its own path (admin already receives merged features via gql at init). +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 -- [ ] Decision 1 recorded; follow-up scoped (remove or retarget `GetFeatureFlagsWithLicense`). -- [ ] Decision 2 recorded; `wcp` query fate scoped. +- [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. ---