Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a859ad1
refactor(event-handler-core): make the handler a DI-native app
adrians5j Aug 3, 2026
5d1fe6b
refactor(event-handler-core): unify createHandler options with Handle…
adrians5j Aug 3, 2026
b849763
refactor: replace createHandler with HandlerRuntime.init (ProjectSdk-…
adrians5j Aug 3, 2026
eaa5d63
refactor: rename HandlerRuntime → EventDispatcher
adrians5j Aug 3, 2026
f61e203
refactor(event-handler-core): split lifecycle abstractions from defau…
adrians5j Aug 3, 2026
1344376
docs(plans): sync handler app name to EventDispatcher
adrians5j Aug 3, 2026
fe54799
refactor: rename runtime var → dispatcher (EventDispatcher leftover)
adrians5j Aug 3, 2026
19ae33c
refactor: rename EventDispatcher → EventProcessor (.init/.process)
adrians5j Aug 3, 2026
a187eb5
refactor: rename EventProcessor → HandlerApp (.init/.handle)
adrians5j Aug 3, 2026
3eb581d
refactor: rename HandlerApp → EventDispatcher (.init/.handle)
adrians5j Aug 3, 2026
9e88da2
refactor: rename EventDispatcher → HandlerApp (Express-style app.handle)
adrians5j Aug 3, 2026
e6ee236
docs(plans): note HandlerApp (generic) vs WebinyApiApp (composition) …
adrians5j Aug 3, 2026
42a0126
refactor: add HandlerApp.getRootContainer; drop rootContainer config …
adrians5j Aug 3, 2026
4276437
refactor(event-handler-server): build root eagerly only when onServer…
adrians5j Aug 3, 2026
e44fc70
docs(plans): note HandlerConfig root/request → rootContainer/childCon…
adrians5j Aug 3, 2026
dc92d9f
docs(plans): resolve Phase 2 decisions (no build-side change; add fea…
adrians5j Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/event-handler-aws/src/createLambdaHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHandler } from "@webiny/event-handler-core";
import { HandlerApp } from "@webiny/event-handler-core";
import type { HandlerSetup } from "@webiny/event-handler-core";
import type { Context } from "@webiny/aws-sdk/types/index.js";
import { awsLambdaTransport } from "./AwsLambdaTransport.js";
Expand All @@ -14,11 +14,11 @@ export interface CreateLambdaHandlerOptions {
* lives in {@link awsLambdaTransport}; everything else is the shared handler loop.
*/
export function createLambdaHandler(options: CreateLambdaHandlerOptions) {
const handle = createHandler({
const app = HandlerApp.init({
root: options.root,
request: options.request,
transport: awsLambdaTransport
});

return (event: any, context?: Context): Promise<any> => handle(event, context);
return (event: any, context?: Context): Promise<any> => app.handle(event, context);
}
16 changes: 8 additions & 8 deletions packages/event-handler-core/__tests__/EventType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EventHandler } from "~/features/events/EventHandler.js";
import { EventType } from "~/features/events/EventType.js";
import type { IEventHandler, EventContext } from "~/features/events/EventHandler.js";
import type { NextFunction } from "~/features/events/types.js";
import { createHandler } from "~/features/events/createHandler.js";
import { HandlerApp } from "~/features/events/HandlerApp.js";

describe("EventType dispatch", () => {
it("should route to correct handler based on canHandle", async () => {
Expand All @@ -30,14 +30,14 @@ describe("EventType dispatch", () => {
dependencies: []
});

const invoke = createHandler({
const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(handler);
}
});

const result = await invoke({
const result = await app.handle({
method: "GET",
path: "/test",
headers: {},
Expand All @@ -62,13 +62,13 @@ describe("EventType dispatch", () => {
dependencies: []
});

const invoke = createHandler({
const app = HandlerApp.init({
root: container => {
container.register(httpType);
}
});

await expect(invoke({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow(
await expect(app.handle({ Records: [{ eventSource: "aws:s3" }] })).rejects.toThrow(
"No event type matched the incoming event"
);
});
Expand Down Expand Up @@ -123,7 +123,7 @@ describe("EventType dispatch", () => {
dependencies: []
});

const invoke = createHandler({
const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(otherType);
Expand All @@ -133,7 +133,7 @@ describe("EventType dispatch", () => {
});

expect(
await invoke({
await app.handle({
method: "GET",
path: "/",
headers: {},
Expand All @@ -142,6 +142,6 @@ describe("EventType dispatch", () => {
body: undefined
})
).toBe("http");
expect(await invoke({ Records: [{}] })).toBe("other");
expect(await app.handle({ Records: [{}] })).toBe("other");
});
});
137 changes: 137 additions & 0 deletions packages/event-handler-core/__tests__/HandlerApp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest";
import { EventHandler } from "~/features/events/EventHandler.js";
import { EventType } from "~/features/events/EventType.js";
import type { IEventType } from "~/features/events/EventType.js";
import { ChildContainerFactory, RootContainerFactory } from "~/features/events/abstractions.js";
import type { IEventHandler } from "~/features/events/EventHandler.js";
import { HandlerApp } from "~/features/events/HandlerApp.js";

describe("HandlerApp (DI-native handler app)", () => {
class HttpEventType implements IEventType {
canHandle(e: any): e is any {
return !!e.method;
}
getHandlerAbstraction() {
return EventHandler;
}
}

const httpType = EventType.createImplementation({
implementation: HttpEventType,
dependencies: []
});

const httpEvent = { method: "GET", path: "/" };

const okHandler = () => {
class OkHandler implements IEventHandler {
async execute() {
return "ok";
}
}
return EventHandler.createImplementation({ implementation: OkHandler, dependencies: [] });
};

it("dispatches an event like the previous closure", async () => {
const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(okHandler());
}
});

expect(await app.handle(httpEvent)).toBe("ok");
});

it("runs a ChildContainerFactory decorator on every request (the seam)", async () => {
const calls: string[] = [];

class CountingChildContainerFactory implements ChildContainerFactory.Interface {
constructor(private decoratee: ChildContainerFactory.Interface) {}
async create(root: any, rawArgs: any[]) {
calls.push("before");
const child = await this.decoratee.create(root, rawArgs);
calls.push("after");
return child;
}
}

const decorator = ChildContainerFactory.createDecorator({
decorator: CountingChildContainerFactory,
dependencies: []
});

const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(okHandler());
},
app: container => {
container.registerDecorator(decorator);
}
});

expect(await app.handle(httpEvent)).toBe("ok");
expect(await app.handle(httpEvent)).toBe("ok");

// Decorator wraps create() once per request (before + after), twice over two invocations.
expect(calls).toEqual(["before", "after", "before", "after"]);
});

it("builds the root container once and reuses it across invocations", async () => {
let rootBuilds = 0;

class CountingRootContainerFactory implements RootContainerFactory.Interface {
constructor(private decoratee: RootContainerFactory.Interface) {}
async get() {
const root = await this.decoratee.get();
rootBuilds++;
return root;
}
}

let rootSetupCalls = 0;
const decorator = RootContainerFactory.createDecorator({
decorator: CountingRootContainerFactory,
dependencies: []
});

const app = HandlerApp.init({
root: container => {
rootSetupCalls++;
container.register(httpType);
container.register(okHandler());
},
app: container => {
container.registerDecorator(decorator);
}
});

await app.handle(httpEvent);
await app.handle(httpEvent);

// get() is called per request, but the underlying root is built (root setup runs) only once.
expect(rootBuilds).toBe(2);
expect(rootSetupCalls).toBe(1);
});

it("getRootContainer builds the root eagerly and handle() reuses it", async () => {
let rootSetupCalls = 0;

const app = HandlerApp.init({
root: container => {
rootSetupCalls++;
container.register(httpType);
container.register(okHandler());
}
});

const root1 = await app.getRootContainer();
const root2 = await app.getRootContainer();
await app.handle(httpEvent);

// Same instance every time; root setup runs exactly once across getter + handle.
expect(root1).toBe(root2);
expect(rootSetupCalls).toBe(1);
});
});
10 changes: 5 additions & 5 deletions packages/event-handler-core/__tests__/RequestInitializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { IEventType } from "~/features/events/EventType.js";
import { RequestInitializer } from "~/features/events/RequestInitializer.js";
import type { IEventHandler, EventContext } from "~/features/events/EventHandler.js";
import type { NextFunction } from "~/features/events/types.js";
import { createHandler } from "~/features/events/createHandler.js";
import { HandlerApp } from "~/features/events/HandlerApp.js";

describe("RequestInitializer", () => {
class HttpEventType implements IEventType {
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("RequestInitializer", () => {
dependencies: []
});

const invoke = createHandler({
const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(handler);
Expand All @@ -78,7 +78,7 @@ describe("RequestInitializer", () => {
}
});

await invoke(httpEvent);
await app.handle(httpEvent);

expect(order).toEqual(["a", "b", "handler"]);
});
Expand All @@ -95,13 +95,13 @@ describe("RequestInitializer", () => {
dependencies: []
});

const invoke = createHandler({
const app = HandlerApp.init({
root: container => {
container.register(httpType);
container.register(handler);
}
});

expect(await invoke(httpEvent)).toBe("ok");
expect(await app.handle(httpEvent)).toBe("ok");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Container } from "@webiny/di";
import { ChildContainerFactory, HandlerConfig } from "./abstractions.js";
import { RequestContainer } from "./RequestContainer.js";
import { RequestInitializer } from "./RequestInitializer.js";
import { noopTransport } from "./Transport.js";

class ChildContainerFactoryImpl implements ChildContainerFactory.Interface {
constructor(private config: HandlerConfig.Interface) {}

async create(root: Container, rawArgs: any[]): Promise<Container> {
const child = root.createChildContainer();
child.registerInstance(RequestContainer, child);

// Transport-specific bind: register the raw platform arguments into the request container
// before request setup runs. The default transport binds nothing.
const transport = this.config.transport ?? noopTransport;
await transport.bind(child, ...rawArgs);

if (this.config.request) {
await this.config.request(child);
}

// Per-request async initialization (tenant-agnostic), before the event is dispatched and
// before auth/tenant are established. For tenant-dependent setup use lazy DI factories.
for (const initializer of child.resolveAll(RequestInitializer)) {
await initializer.init();
}

return child;
}
}

export const DefaultChildContainerFactory = ChildContainerFactory.createImplementation({
implementation: ChildContainerFactoryImpl,
dependencies: [HandlerConfig]
});
90 changes: 90 additions & 0 deletions packages/event-handler-core/src/features/events/HandlerApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Container } from "@webiny/di";
import { EventType } from "./EventType.js";
import { HandlerConfig, RootContainerFactory, ChildContainerFactory } from "./abstractions.js";
import { DefaultRootContainerFactory } from "./RootContainerFactory.js";
import { DefaultChildContainerFactory } from "./ChildContainerFactory.js";
import { executeChain } from "./chain.js";

/**
* The DI-native handler app. `HandlerApp.init(config)` builds a small "app container" (distinct
* from the per-process root container and the per-request child container it goes on to create),
* wires the default lifecycle abstractions, and returns an app whose `handle()` is the
* platform-invocable handler.
*
* The lifecycle is delegated to decoratable DI abstractions — {@link RootContainerFactory} (build
* the root once) and {@link ChildContainerFactory} (create + set up the per-request child) — so
* transports/composition layers extend it by decoration (`config.app`) instead of this class
* growing new branches. `HandlerApp` is distinct from {@link EventHandler}, which is a single
* handler IN the dispatch chain.
*/
export class HandlerApp {
private constructor(
private rootContainerFactory: RootContainerFactory.Interface,
private childContainerFactory: ChildContainerFactory.Interface
) {}

static init(config: HandlerConfig.Interface): HandlerApp {
const appContainer = new Container();

// Register the config as-is — the default lifecycle factories resolve HandlerConfig directly.
appContainer.registerInstance(HandlerConfig, config);

// Register the default lifecycle abstractions. Singleton-scoped so the memoized root
// container (held by RootContainerFactory) is shared across every warm invocation.
appContainer.register(DefaultRootContainerFactory).inSingletonScope();
appContainer.register(DefaultChildContainerFactory).inSingletonScope();

// Seam: let callers decorate the factories before the app is resolved.
config.app?.(appContainer);

// Resolve the factories once (decorators applied) so their state — notably the memoized
// root — is reused across every invocation of handle().
return new HandlerApp(
appContainer.resolve(RootContainerFactory),
appContainer.resolve(ChildContainerFactory)
);
}

/**
* Build (once) and return the root container. Lets a transport that needs the root before the
* first request — e.g. the Node server attaching a WebSockets upgrade handler at startup — get
* it eagerly. The same memoized root is then reused by every `handle()` call.
*/
getRootContainer(): Promise<Container> {
return this.rootContainerFactory.get();
}

async handle(...rawArgs: any[]): Promise<any> {
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);
}
}
Loading
Loading