refactor: harden msteams lifecycle and attachment flows

This commit is contained in:
Peter Steinberger
2026-03-02 21:18:22 +00:00
parent d98a61a977
commit 866bd91c65
15 changed files with 459 additions and 112 deletions

View File

@@ -0,0 +1,66 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js";
type FakeServer = EventEmitter & {
close: (callback?: () => void) => void;
};
function createFakeServer(): FakeServer {
const server = new EventEmitter() as FakeServer;
server.close = (callback) => {
queueMicrotask(() => {
server.emit("close");
callback?.();
});
};
return server;
}
describe("plugin-sdk channel lifecycle helpers", () => {
it("resolves waitUntilAbort when signal aborts", async () => {
const abort = new AbortController();
const task = waitUntilAbort(abort.signal);
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
abort.abort();
await expect(task).resolves.toBeUndefined();
});
it("keeps server task pending until close, then resolves", async () => {
const server = createFakeServer();
const task = keepHttpServerTaskAlive({ server });
const early = await Promise.race([
task.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
]);
expect(early).toBe("pending");
server.close();
await expect(task).resolves.toBeUndefined();
});
it("triggers abort hook once and resolves after close", async () => {
const server = createFakeServer();
const abort = new AbortController();
const onAbort = vi.fn(async () => {
server.close();
});
const task = keepHttpServerTaskAlive({
server,
abortSignal: abort.signal,
onAbort,
});
abort.abort();
await expect(task).resolves.toBeUndefined();
expect(onAbort).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,66 @@
type CloseAwareServer = {
once: (event: "close", listener: () => void) => unknown;
};
/**
* Return a promise that resolves when the signal is aborted.
*
* If no signal is provided, the promise stays pending forever.
*/
export function waitUntilAbort(signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
if (!signal) {
return;
}
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
}
/**
* Keep a channel/provider task pending until the HTTP server closes.
*
* When an abort signal is provided, `onAbort` is invoked once and should
* trigger server shutdown. The returned promise resolves only after `close`.
*/
export async function keepHttpServerTaskAlive(params: {
server: CloseAwareServer;
abortSignal?: AbortSignal;
onAbort?: () => void | Promise<void>;
}): Promise<void> {
const { server, abortSignal, onAbort } = params;
let abortTask: Promise<void> = Promise.resolve();
let abortTriggered = false;
const triggerAbort = () => {
if (abortTriggered) {
return;
}
abortTriggered = true;
abortTask = Promise.resolve(onAbort?.()).then(() => undefined);
};
const onAbortSignal = () => {
triggerAbort();
};
if (abortSignal) {
if (abortSignal.aborted) {
triggerAbort();
} else {
abortSignal.addEventListener("abort", onAbortSignal, { once: true });
}
}
await new Promise<void>((resolve) => {
server.once("close", () => resolve());
});
if (abortSignal) {
abortSignal.removeEventListener("abort", onAbortSignal);
}
await abortTask;
}

View File

@@ -149,6 +149,7 @@ export {
WEBHOOK_IN_FLIGHT_DEFAULTS,
} from "./webhook-request-guards.js";
export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js";
export { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js";
export type { AgentMediaPayload } from "./agent-media-payload.js";
export { buildAgentMediaPayload } from "./agent-media-payload.js";
export {