Initial Commit
This commit is contained in:
57
packages/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal file
57
packages/api-stack/api/api-plugin-sse/src/schemas/event.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-plugin-sse/src/schemas/event.ts
Normal file
18
packages/api-stack/api/api-plugin-sse/src/schemas/event.ts
Normal 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>;
|
||||
10
packages/api-stack/api/api-plugin-sse/src/schemas/index.ts
Normal file
10
packages/api-stack/api/api-plugin-sse/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type SseEventInputParsed,
|
||||
type SseEventResolved,
|
||||
SseEventSchema,
|
||||
} from "./event.js";
|
||||
export {
|
||||
type SsePluginOptionsInput,
|
||||
type SsePluginOptionsResolved,
|
||||
SsePluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
75
packages/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal file
75
packages/api-stack/api/api-plugin-sse/src/schemas/options.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
60
packages/api-stack/api/api-plugin-sse/src/schemas/options.ts
Normal file
60
packages/api-stack/api/api-plugin-sse/src/schemas/options.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user