mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 16:41:49 +00:00
refactor(gateway): unify control-ui and plugin webhook routing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user