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 @@
# event
## Purpose
Zod schema validating the `SseEvent` shape accepted by `SseService.broadcast`. Ensures application payloads cannot bypass the boundary with malformed ids, negative `retry` values, or empty event names. Validation failure raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` chained as `cause` so consumers can surface field-level diagnostics to upstream handlers.
## Features
- `id` is optional at the boundary — the service generates one via the configured `idGenerator` when absent. When supplied it must be a non-empty string.
- `event` (the SSE `event:` field) optional but non-empty when supplied.
- `data` accepts any JSON-serialisable value (`z.unknown()`); the handler is responsible for JSON serialisation on the wire.
- `retry` constrained to positive integers so a `retry: 0` or `retry: -1` cannot be sent (both are confusing in browsers).
## Usage: Basic
```ts
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
const parsed = SseEventSchema.parse({ data: { kind: "ping" } });
// parsed.id is undefined; the service fills it from idGenerator
```
## Usage: Advanced
```ts
import { SseEventSchema } from "@suaveplan/api-plugin-sse/schemas/event";
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
function safeBroadcast(service: { broadcast: (c: string, e: unknown) => void }, channel: string, raw: unknown) {
const parsed = SseEventSchema.safeParse(raw);
if (!parsed.success) {
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE event", {
cause: parsed.error,
context: { channel, raw },
});
}
service.broadcast(channel, parsed.data);
}
safeBroadcast(service, "notifications", { event: "ping", data: { ts: Date.now() } });
```
## API Reference
- `SseEventSchema` — Zod object schema. Fields:
- `id` — optional non-empty string.
- `event` — optional non-empty string.
- `data` — `z.unknown()` (any JSON-serialisable value).
- `retry` — optional positive integer (milliseconds).
- `type SseEventInputParsed` — `z.input<typeof SseEventSchema>` (caller-facing).
- `type SseEventResolved` — `z.output<typeof SseEventSchema>` (post-validation).
## Implementation Notes
- The schema is intentionally NOT `.strict()` because applications may pass through additional metadata as part of the broadcast call (`tags`, `severity`, etc.). The service does not propagate unknown fields to the wire — only `id`, `event`, `retry`, `data` are framed.
- `data: undefined` is rejected — `undefined` cannot be JSON-stringified and would silently drop the payload at framing time.
- A stricter `data` schema (e.g. `z.object({ kind: z.string() })`) belongs in application code that composes its own broadcast wrappers. The plugin level is intentionally permissive to support arbitrary payloads.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SseEventSchema } from "./event.js";
describe("SseEventSchema", () => {
it("accepts arbitrary JSON data", { timeout: 5000 }, () => {
const r = SseEventSchema.safeParse({
id: "1",
data: { nested: { arr: [1, 2] } },
});
expect(r.success).toBe(true);
});
it("accepts data: null", { timeout: 5000 }, () => {
const r = SseEventSchema.safeParse({ data: null });
expect(r.success).toBe(true);
});
it("rejects empty-string id", { timeout: 5000 }, () => {
const r = SseEventSchema.safeParse({ id: "", data: 1 });
expect(r.success).toBe(false);
});
it("rejects non-positive retry", { timeout: 5000 }, () => {
const r = SseEventSchema.safeParse({ data: 1, retry: 0 });
expect(r.success).toBe(false);
});
it("accepts string event name", { timeout: 5000 }, () => {
const r = SseEventSchema.safeParse({ event: "ping", data: 1 });
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
import z from "zod";
/**
* Zod schema for SSE events accepted by `SseService.broadcast`. `data`
* may be any JSON-serialisable value (object, array, string, number,
* boolean, null) so application payloads flow through without forcing
* callers to pre-stringify. The handler is responsible for JSON
* serialisation on the wire.
*/
export const SseEventSchema = z.object({
id: z.string().min(1).optional(),
event: z.string().min(1).optional(),
data: z.unknown(),
retry: z.number().int().positive().optional(),
});
export type SseEventInputParsed = z.input<typeof SseEventSchema>;
export type SseEventResolved = z.output<typeof SseEventSchema>;

View File

@@ -0,0 +1,10 @@
export {
type SseEventInputParsed,
type SseEventResolved,
SseEventSchema,
} from "./event.js";
export {
type SsePluginOptionsInput,
type SsePluginOptionsResolved,
SsePluginOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,75 @@
# options
## Purpose
Zod schema validating `SsePluginOptions` at the factory boundary. `createSsePlugin` calls `SsePluginOptionsSchema.safeParse(options)` and raises `SseError(SSE_INVALID_INPUT)` with the `ZodError` as `cause` on failure. The schema also encodes the canonical defaults so the plugin descriptor's resolved config reflects every documented default value without the implementation hand-coding them.
## Features
- Strict object validation rejects unknown keys to catch typos like `heartBeatInterval`.
- Defaults applied via Zod's `.default()` so the resolved type has every field non-optional.
- Numeric fields constrained: positive integers for time/cap fields; non-negative integer for retention (zero disables replay).
- `routePrefix` regex matches `/`, `/sse`, `/v1/sse`, rejecting trailing-slash and missing-leading-slash forms.
- Runtime objects (`idGenerator`, `clock`, `logger`) validated structurally via `z.custom` so duck-typed implementations are accepted.
## Usage: Basic
```ts
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
const parsed = SsePluginOptionsSchema.parse({
heartbeatIntervalMs: 15_000,
maxConnectionsPerPrincipal: 4,
});
console.log(parsed.routePrefix); // "/sse" (defaulted)
```
## Usage: Advanced
```ts
import { SsePluginOptionsSchema } from "@suaveplan/api-plugin-sse/schemas/options";
import { SSE_CODES, SseError } from "@suaveplan/api-plugin-sse/error-codes/error-codes";
function validateOrThrow(input: unknown) {
const result = SsePluginOptionsSchema.safeParse(input);
if (!result.success) {
throw new SseError(SSE_CODES.INVALID_INPUT, "invalid SSE config", {
cause: result.error,
});
}
return result.data;
}
// User-supplied logger duck-typed against LoggerLike — accepted.
validateOrThrow({
logger: {
debug: console.debug,
info: console.info,
warn: console.warn,
error: console.error,
},
});
```
## API Reference
- `SsePluginOptionsSchema` — Zod object schema. Fields and defaults:
- `heartbeatIntervalMs` — positive integer (default `30_000`).
- `routePrefix` — string matching `^/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^/$` (default `"/sse"`).
- `attachDefaultRoute` — boolean (default `true`).
- `retentionPerChannel` — non-negative integer (default `100`).
- `maxConnectionsPerPrincipal` — positive integer (default `16`).
- `maxTotalConnections` — positive integer (default `10_000`).
- `queueHighWaterMark` — positive integer (default `256`).
- `overflowPolicy` — `"drop-oldest" | "drop-newest"` (default `"drop-oldest"`).
- `idGenerator` — optional `() => string`.
- `clock` — optional `Clock` from `@suaveplan/types`.
- `logger` — optional `LoggerLike` from `@suaveplan/types`.
- `type SsePluginOptionsInput` — `z.input<typeof SsePluginOptionsSchema>` (caller-facing shape).
- `type SsePluginOptionsResolved` — `z.output<typeof SsePluginOptionsSchema>` (post-default shape).
## Implementation Notes
- The schema is `.strict()` — unknown keys produce a `ZodIssueCode.unrecognized_keys` issue. Intentionally strict so a typo doesn't silently fall back to a default.
- The runtime-object validators do not require structural typing of every method — they sniff the presence of one canonical method (`logger.info`, `clock.now`) so callers can pass extended objects without rewriting them.
- Defaults are intentionally generous for production use; tighten them in your `createSsePlugin` call if you run behind aggressive proxies or with constrained memory budgets.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SsePluginOptionsSchema } from "./options.js";
describe("SsePluginOptionsSchema", () => {
it("applies defaults when nothing is supplied", { timeout: 5000 }, () => {
const parsed = SsePluginOptionsSchema.parse({});
expect(parsed.heartbeatIntervalMs).toBe(30_000);
expect(parsed.routePrefix).toBe("/sse");
expect(parsed.attachDefaultRoute).toBe(true);
expect(parsed.retentionPerChannel).toBe(100);
expect(parsed.maxConnectionsPerPrincipal).toBe(16);
expect(parsed.maxTotalConnections).toBe(10_000);
expect(parsed.queueHighWaterMark).toBe(256);
expect(parsed.overflowPolicy).toBe("drop-oldest");
});
it("rejects non-positive heartbeat", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ heartbeatIntervalMs: 0 });
expect(r.success).toBe(false);
});
it("rejects non-integer retention", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({
retentionPerChannel: 1.5,
});
expect(r.success).toBe(false);
});
it("rejects invalid overflowPolicy", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({
overflowPolicy: "explode",
});
expect(r.success).toBe(false);
});
it("rejects routePrefix without leading slash", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ routePrefix: "sse" });
expect(r.success).toBe(false);
});
it("accepts a custom idGenerator function", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ idGenerator: () => "x" });
expect(r.success).toBe(true);
});
it("rejects a non-function idGenerator", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ idGenerator: 42 });
expect(r.success).toBe(false);
});
it("accepts a LoggerLike object", { timeout: 5000 }, () => {
const logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const r = SsePluginOptionsSchema.safeParse({ logger });
expect(r.success).toBe(true);
});
it("rejects a non-LoggerLike value", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ logger: {} });
expect(r.success).toBe(false);
});
it("accepts a Clock object", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ clock: { now: () => 0 } });
expect(r.success).toBe(true);
});
it("rejects a non-Clock value", { timeout: 5000 }, () => {
const r = SsePluginOptionsSchema.safeParse({ clock: {} });
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import z from "zod";
/**
* Zod schema for `SsePluginOptions`. Every numeric field is positive
* and integer-valued; `overflowPolicy` is the documented enum;
* callables / Clock / Logger pass through via `z.custom` since they
* are runtime-object contracts whose internals are out of scope for
* boundary validation.
*
* Defaults applied here are the canonical plugin defaults — `onInit`
* reads the parsed output directly.
*/
export const SsePluginOptionsSchema = z
.object({
heartbeatIntervalMs: z.number().int().positive().default(30_000),
routePrefix: z
.string()
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
message:
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
})
.default("/sse"),
attachDefaultRoute: z.boolean().default(true),
retentionPerChannel: z.number().int().nonnegative().default(100),
maxConnectionsPerPrincipal: z.number().int().positive().default(16),
maxTotalConnections: z.number().int().positive().default(10_000),
queueHighWaterMark: z.number().int().positive().default(256),
overflowPolicy: z
.enum(["drop-oldest", "drop-newest"])
.default("drop-oldest"),
idGenerator: z
.custom<() => string>(
(val) => typeof val === "function",
"idGenerator must be a () => string function"
)
.optional(),
clock: z
.custom<Clock>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { now?: unknown }).now === "function",
"clock must be a Clock with now()"
)
.optional(),
logger: z
.custom<LoggerLike>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { info?: unknown }).info === "function",
"logger must be a LoggerLike with debug/info/warn/error"
)
.optional(),
})
.strict();
export type SsePluginOptionsInput = z.input<typeof SsePluginOptionsSchema>;
export type SsePluginOptionsResolved = z.output<typeof SsePluginOptionsSchema>;