refactor(gateway): unify control-ui and plugin webhook routing

This commit is contained in:
Peter Steinberger
2026-03-02 16:17:31 +00:00
parent 21708f58ce
commit b13d48987c
17 changed files with 870 additions and 425 deletions

View File

@@ -128,7 +128,7 @@ export {
resolveSingleWebhookTargetAsync,
resolveWebhookTargets,
} from "./webhook-targets.js";
export type { WebhookTargetMatchResult } from "./webhook-targets.js";
export type { RegisterWebhookTargetOptions, WebhookTargetMatchResult } from "./webhook-targets.js";
export {
applyBasicWebhookRequestGuards,
isJsonContentType,

View File

@@ -31,6 +31,59 @@ describe("registerWebhookTarget", () => {
registered.unregister();
expect(targets.has("/hook")).toBe(false);
});
it("runs first/last path lifecycle hooks only at path boundaries", () => {
const targets = new Map<string, Array<{ path: string; id: string }>>();
const teardown = vi.fn();
const onFirstPathTarget = vi.fn(() => teardown);
const onLastPathTargetRemoved = vi.fn();
const registeredA = registerWebhookTarget(
targets,
{ path: "hook", id: "A" },
{ onFirstPathTarget, onLastPathTargetRemoved },
);
const registeredB = registerWebhookTarget(
targets,
{ path: "/hook", id: "B" },
{ onFirstPathTarget, onLastPathTargetRemoved },
);
expect(onFirstPathTarget).toHaveBeenCalledTimes(1);
expect(onFirstPathTarget).toHaveBeenCalledWith({
path: "/hook",
target: expect.objectContaining({ id: "A", path: "/hook" }),
});
registeredB.unregister();
expect(teardown).not.toHaveBeenCalled();
expect(onLastPathTargetRemoved).not.toHaveBeenCalled();
registeredA.unregister();
expect(teardown).toHaveBeenCalledTimes(1);
expect(onLastPathTargetRemoved).toHaveBeenCalledTimes(1);
expect(onLastPathTargetRemoved).toHaveBeenCalledWith({ path: "/hook" });
registeredA.unregister();
expect(teardown).toHaveBeenCalledTimes(1);
expect(onLastPathTargetRemoved).toHaveBeenCalledTimes(1);
});
it("does not register target when first-path hook throws", () => {
const targets = new Map<string, Array<{ path: string; id: string }>>();
expect(() =>
registerWebhookTarget(
targets,
{ path: "/hook", id: "A" },
{
onFirstPathTarget: () => {
throw new Error("boom");
},
},
),
).toThrow("boom");
expect(targets.has("/hook")).toBe(false);
});
});
describe("resolveWebhookTargets", () => {

View File

@@ -6,21 +6,65 @@ export type RegisteredWebhookTarget<T> = {
unregister: () => void;
};
export type RegisterWebhookTargetOptions<T extends { path: string }> = {
onFirstPathTarget?: (params: { path: string; target: T }) => void | (() => void);
onLastPathTargetRemoved?: (params: { path: string }) => void;
};
const pathTeardownByTargetMap = new WeakMap<Map<string, unknown[]>, Map<string, () => void>>();
function getPathTeardownMap<T>(targetsByPath: Map<string, T[]>): Map<string, () => void> {
const mapKey = targetsByPath as unknown as Map<string, unknown[]>;
const existing = pathTeardownByTargetMap.get(mapKey);
if (existing) {
return existing;
}
const created = new Map<string, () => void>();
pathTeardownByTargetMap.set(mapKey, created);
return created;
}
export function registerWebhookTarget<T extends { path: string }>(
targetsByPath: Map<string, T[]>,
target: T,
opts?: RegisterWebhookTargetOptions<T>,
): RegisteredWebhookTarget<T> {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = targetsByPath.get(key) ?? [];
if (existing.length === 0) {
const onFirstPathResult = opts?.onFirstPathTarget?.({
path: key,
target: normalizedTarget,
});
if (typeof onFirstPathResult === "function") {
getPathTeardownMap(targetsByPath).set(key, onFirstPathResult);
}
}
targetsByPath.set(key, [...existing, normalizedTarget]);
let isActive = true;
const unregister = () => {
if (!isActive) {
return;
}
isActive = false;
const updated = (targetsByPath.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
targetsByPath.set(key, updated);
return;
}
targetsByPath.delete(key);
const teardown = getPathTeardownMap(targetsByPath).get(key);
if (teardown) {
getPathTeardownMap(targetsByPath).delete(key);
teardown();
}
opts?.onLastPathTargetRemoved?.({ path: key });
};
return { target: normalizedTarget, unregister };
}