Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(here, "..");
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as {
exports: Record<string, unknown>;
sideEffects: boolean;
};
describe("dep-graph", () => {
it("declares 14 tree-shakeable subpath exports", { timeout: 5000 }, () => {
const sub = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// 13 subpaths + the root "." = 14.
expect(sub.length + 1).toBe(14);
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
walk(join(pkgRoot, "src"), (path) => {
if (!path.endsWith(".ts")) return;
if (path.endsWith(".test.ts")) return;
if (path.endsWith(".types.ts")) return;
if (path.endsWith("/index.ts")) return;
if (path.endsWith(".spec-conformance.test.ts")) return;
const md = path.replace(/\.ts$/, ".md");
if (!existsSync(md)) missing.push(md);
});
expect(missing).toEqual([]);
});
});
function walk(dir: string, visit: (path: string) => void): void {
for (const entry of readdirSyncSafe(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, visit);
else visit(full);
}
}
function readdirSyncSafe(dir: string): string[] {
try {
const { readdirSync } = require("node:fs") as typeof import("node:fs");
return readdirSync(dir);
} catch {
return [];
}
}

View File

@@ -0,0 +1,97 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
WEBHOOK_CODES,
} from "../../src/index.js";
async function startReceiver(
handler: (req: IncomingMessage, res: ServerResponse) => void
) {
const server: Server = createServer((req, res) => {
req.on("data", () => undefined);
req.on("end", () => handler(req, res));
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook dead letter", () => {
it("5x 500 lands in dead letter", { timeout: 30_000 }, async () => {
let attempts = 0;
const receiver = await startReceiver((_, res) => {
attempts++;
res.writeHead(500);
res.end("nope");
});
try {
resetWebhookInstruments();
const sink = new MemoryDeadLetterSink();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: sink,
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 30,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["evt"],
});
await expect(
service.send("ep-1", "evt", { x: 1 })
).rejects.toMatchObject({
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
});
expect(attempts).toBe(5);
const dl = await sink.list({ endpointId: "ep-1" });
expect(dl).toHaveLength(1);
const entry = dl[0];
if (!entry) throw new Error("missing dl entry");
expect(entry.event).toBe("evt");
expect(entry.endpointId).toBe("ep-1");
expect(entry.payload.event).toBe("evt");
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,143 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { verifySignature } from "@suaveplan/webhook-client";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
} from "../../src/index.js";
interface CapturedRequest {
method: string;
url: string;
headers: Record<string, string>;
body: string;
}
interface TestReceiver {
readonly url: string;
readonly requests: CapturedRequest[];
close(): Promise<void>;
}
type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
attempt: number
) => void;
async function startReceiver(handler: RequestHandler): Promise<TestReceiver> {
const requests: CapturedRequest[] = [];
let attempt = 0;
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
attempt += 1;
const captured: CapturedRequest = {
method: req.method ?? "GET",
url: req.url ?? "/",
headers: Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(",") : (v ?? ""),
])
),
body,
};
requests.push(captured);
handler(req, res, attempt);
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind test receiver");
}
const url = `http://127.0.0.1:${address.port}/hooks`;
return {
url,
requests,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook delivery", () => {
it("sends a signed payload with canonical headers", {
timeout: 30_000,
}, async () => {
const receiver = await startReceiver((_, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 1,
initialRetryDelayMs: 1,
maxRetryDelayMs: 100,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["order.created"],
});
const result = await service.send("ep-1", "order.created", {
orderId: 42,
});
expect(result.status).toBe("delivered");
expect(receiver.requests).toHaveLength(1);
const req = receiver.requests[0];
if (!req) throw new Error("missing request");
expect(req.method).toBe("POST");
expect(req.headers["x-webhook-event"]).toBe("order.created");
expect(req.headers["x-webhook-id"]).toBeTruthy();
expect(req.headers["x-webhook-timestamp"]).toBeTruthy();
const sig = req.headers["x-hub-signature-256"];
expect(sig).toMatch(/^sha256=/);
const verified = await verifySignature(
req.body,
sig as string,
"shared-secret",
"sha256"
);
expect(verified).toBe(true);
expect(JSON.parse(req.body).data).toEqual({ orderId: 42 });
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,125 @@
import { createServer, type Server } from "node:http";
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookPlugin,
resetWebhookInstruments,
type WebhookPluginConfig,
type WebhookService,
} from "../../src/index.js";
interface Receiver {
readonly url: string;
readonly observed: { event: string; body: string }[];
close(): Promise<void>;
}
async function startReceiver(): Promise<Receiver> {
const observed: { event: string; body: string }[] = [];
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (c) => {
body += c;
});
req.on("end", () => {
const evt = req.headers["x-webhook-event"];
observed.push({
event: Array.isArray(evt) ? (evt[0] ?? "") : (evt ?? ""),
body,
});
res.writeHead(200);
res.end("ok");
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
observed,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("integration: webhook plugin full boot", () => {
it("POST /webhooks/:id/test fires a synthetic webhook.ping", {
timeout: 30_000,
}, async () => {
const receiver = await startReceiver();
try {
resetWebhookInstruments();
const app = new OpenAPIHono();
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } })
.context;
const status = (ctx?.statusCode ?? 500) as
| 401
| 403
| 404
| 500;
return c.json({ message: err?.message ?? "error" }, status);
});
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
// Install a permissive auth middleware ABOVE the plugin's
// /webhooks/* requireAuth so the integration test does not
// need a real auth plugin.
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: null });
await next();
});
const descriptor = createWebhookPlugin();
const ctx = createPluginContext<WebhookPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: descriptor.defaults as WebhookPluginConfig,
services,
});
await descriptor.onInit?.(ctx);
// Register an endpoint via the service that the plugin
// registered in ServiceRegistry.
const service = ctx.getService("webhookService") as WebhookService;
const endpoint = await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["webhook.ping"],
});
// Fire via the test route.
const res = await app.request(`/webhooks/${endpoint.id}/test`, {
method: "POST",
});
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("delivered");
expect(receiver.observed).toHaveLength(1);
expect(receiver.observed[0]?.event).toBe("webhook.ping");
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,141 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createWebhookService,
MemoryDeadLetterSink,
MemoryEndpointStore,
resetWebhookInstruments,
WEBHOOK_CODES,
} from "../../src/index.js";
async function startReceiver(
handler: (
req: IncomingMessage,
res: ServerResponse,
attempt: number
) => void
) {
let attempt = 0;
const server: Server = createServer((req, res) => {
req.on("data", () => undefined);
req.on("end", () => {
attempt += 1;
handler(req, res, attempt);
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("failed to bind");
}
return {
url: `http://127.0.0.1:${address.port}/hooks`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function noopLogger() {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("integration: webhook retry", () => {
it("retries 5xx then succeeds", { timeout: 30_000 }, async () => {
const receiver = await startReceiver((_, res, attempt) => {
if (attempt < 3) {
res.writeHead(500);
res.end("transient");
} else {
res.writeHead(200);
res.end("ok");
}
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 50,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["order.created"],
});
const result = await service.send("ep-1", "order.created", {
x: 1,
});
expect(result.status).toBe("delivered");
expect(result.attempts.length).toBe(3);
expect(result.finalStatusCode).toBe(200);
} finally {
await receiver.close();
}
});
it("4xx terminates without retry", { timeout: 30_000 }, async () => {
let attempts = 0;
const receiver = await startReceiver((_, res) => {
attempts++;
res.writeHead(422);
res.end("client error");
});
try {
resetWebhookInstruments();
const store = new MemoryEndpointStore({
clock: () => 0,
idFactory: () => "ep-1",
});
const service = createWebhookService({
endpointStore: store,
deadLetterSink: new MemoryDeadLetterSink(),
maxAttempts: 5,
initialRetryDelayMs: 1,
maxRetryDelayMs: 50,
idempotencyGuard: null,
logger: noopLogger(),
clock: () => 0,
fetch: globalThis.fetch.bind(globalThis),
});
await service.register({
tenantId: null,
url: receiver.url,
secret: "shared-secret",
events: ["evt"],
});
await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject(
{
webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED,
}
);
expect(attempts).toBe(1);
} finally {
await receiver.close();
}
});
});

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-webhook.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}