@mailwoman/api-kit 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/error.ts +32 -0
- package/geo.ts +39 -0
- package/index.ts +16 -0
- package/metrics.ts +104 -0
- package/openapi.ts +89 -0
- package/package.json +20 -6
- package/serve.ts +40 -0
package/error.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The native error envelope. Surfaces that carry a vendor-compat contract (photon, nominatim,
|
|
7
|
+
* libpostal) keep their own error shapes — this envelope is for surfaces OURS to design (the
|
|
8
|
+
* `@mailwoman/api` native `/v1/*` routes), where nothing constrains the wire shape but us.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "@hono/zod-openapi"
|
|
12
|
+
import type { Context } from "hono"
|
|
13
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|
14
|
+
|
|
15
|
+
/** The native error envelope: a short machine-stable `error` string plus an optional human `detail`. */
|
|
16
|
+
export const APIErrorSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
error: z.string(),
|
|
19
|
+
detail: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
.openapi("APIError")
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Respond with the native error envelope. `status` is generic (not the flat `ContentfulStatusCode` union) so the
|
|
25
|
+
* returned `TypedResponse`'s status stays the CALLER'S literal (e.g. `503`), not the whole union — required for use
|
|
26
|
+
* inside an `app.openapi(route, handler)` handler body (`@mailwoman/api/routes.ts`), where the framework checks the
|
|
27
|
+
* handler's return type against that specific route's declared per-status `responses` map. A flat-typed `status` param
|
|
28
|
+
* would widen every branch to "any content-carrying status", which no single declared response branch matches.
|
|
29
|
+
*/
|
|
30
|
+
export function apiError<S extends ContentfulStatusCode>(c: Context, status: S, error: string, detail?: string) {
|
|
31
|
+
return c.json(detail === undefined ? { error } : { error, detail }, status)
|
|
32
|
+
}
|
package/geo.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* GeoJSON wire atoms shared by the geo-shaped HTTP surfaces (photon, nominatim). Envelope
|
|
7
|
+
* builders only — surface-specific property schemas live with their routes, per the anti-meta
|
|
8
|
+
* guardrails in the 2026-07-12 design spec.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "@hono/zod-openapi"
|
|
12
|
+
|
|
13
|
+
/** A GeoJSON Point geometry: `[lon, lat]`. */
|
|
14
|
+
export const PointGeometrySchema = z
|
|
15
|
+
.object({
|
|
16
|
+
type: z.literal("Point"),
|
|
17
|
+
coordinates: z.tuple([z.number(), z.number()]),
|
|
18
|
+
})
|
|
19
|
+
.openapi("PointGeometry")
|
|
20
|
+
|
|
21
|
+
/** A `[minLon, minLat, maxLon, maxLat]`-style 4-tuple (photon's `extent` uses `[minLon, maxLat, maxLon, minLat]`). */
|
|
22
|
+
export const BBoxSchema = z.tuple([z.number(), z.number(), z.number(), z.number()])
|
|
23
|
+
|
|
24
|
+
/** GeoJSON Feature envelope over a surface-specific properties schema. */
|
|
25
|
+
export function featureSchema<P extends z.ZodTypeAny>(properties: P) {
|
|
26
|
+
return z.object({
|
|
27
|
+
type: z.literal("Feature"),
|
|
28
|
+
geometry: PointGeometrySchema,
|
|
29
|
+
properties,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** GeoJSON FeatureCollection envelope over a feature schema. */
|
|
34
|
+
export function featureCollectionSchema<F extends z.ZodTypeAny>(feature: F) {
|
|
35
|
+
return z.object({
|
|
36
|
+
type: z.literal("FeatureCollection"),
|
|
37
|
+
features: z.array(feature),
|
|
38
|
+
})
|
|
39
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/api-kit` — plumbing for Mailwoman's HTTP surfaces: the node serve wrapper, OpenAPI
|
|
7
|
+
* emit helpers, generic timing metrics, and the native error envelope. Plumbing only, by rule:
|
|
8
|
+
* domain schemas live next to their routes in the package that owns the wire contract (see the
|
|
9
|
+
* 2026-07-12 design spec's anti-meta guardrails).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export * from "./error.ts"
|
|
13
|
+
export * from "./geo.ts"
|
|
14
|
+
export * from "./metrics.ts"
|
|
15
|
+
export * from "./openapi.ts"
|
|
16
|
+
export * from "./serve.ts"
|
package/metrics.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Generic in-process timing metrics (ported from `mailwoman/server/metrics.ts`, #485
|
|
7
|
+
* observability, for the api-kit plumbing layer — see the 2026-07-12 Phase 4a plan's
|
|
8
|
+
* dependency-arrow correction). Dependency-free: monotonic counters per string-keyed tier + a
|
|
9
|
+
* bounded reservoir of recent latencies for percentile estimation. Callers own their tier
|
|
10
|
+
* vocabulary (e.g. `mailwoman`'s `ResolutionTier`); this module only ever sees `string`.
|
|
11
|
+
* Surfaced by `GET /metrics`; reset on process restart (no persistence — scrape it). Per-process
|
|
12
|
+
* state: under `node:cluster` each worker reports its own snapshot — aggregate at the scraper.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Recent-latency reservoir size. ~2k samples gives stable p99 without unbounded memory. */
|
|
16
|
+
const MAX_SAMPLES = 2048
|
|
17
|
+
|
|
18
|
+
const latencies: number[] = []
|
|
19
|
+
let writeIdx = 0
|
|
20
|
+
|
|
21
|
+
/** Null-prototype: tier keys are created lazily on first use, not eagerly pre-populated. */
|
|
22
|
+
const tierCounts: Record<string, number> = Object.create(null)
|
|
23
|
+
let total = 0
|
|
24
|
+
let errors = 0
|
|
25
|
+
const startedAt = Date.now()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Record one completed timed operation: its wall-clock latency and which tier produced it (or `"error"`). Tier keys are
|
|
29
|
+
* created on first use; "error" is reserved and counts toward errors instead of a tier.
|
|
30
|
+
*/
|
|
31
|
+
export function recordTimed(latencyMs: number, tier: string): void {
|
|
32
|
+
total++
|
|
33
|
+
|
|
34
|
+
if (tier === "error") {
|
|
35
|
+
errors++
|
|
36
|
+
} else {
|
|
37
|
+
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (latencies.length < MAX_SAMPLES) {
|
|
41
|
+
latencies.push(latencyMs)
|
|
42
|
+
} else {
|
|
43
|
+
latencies[writeIdx] = latencyMs
|
|
44
|
+
writeIdx = (writeIdx + 1) % MAX_SAMPLES
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function percentile(sorted: number[], p: number): number {
|
|
49
|
+
if (sorted.length === 0) return 0
|
|
50
|
+
const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length))
|
|
51
|
+
|
|
52
|
+
return Math.round(sorted[idx]! * 100) / 100
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MetricsSnapshot {
|
|
56
|
+
uptime_s: number
|
|
57
|
+
timings: {
|
|
58
|
+
total: number
|
|
59
|
+
errors: number
|
|
60
|
+
/**
|
|
61
|
+
* Per-tier counts. Keys are created lazily on the first `recordTimed` call for that tier — a tier never recorded is
|
|
62
|
+
* absent, not zero.
|
|
63
|
+
*/
|
|
64
|
+
tiers: Record<string, number>
|
|
65
|
+
latency_ms: { p50: number; p90: number; p99: number; max: number } | null
|
|
66
|
+
latency_samples: number
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Current metrics snapshot — sorted-reservoir percentiles + counters. */
|
|
71
|
+
export function metricsSnapshot(): MetricsSnapshot {
|
|
72
|
+
const sorted = [...latencies].sort((a, b) => a - b)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
uptime_s: Math.round((Date.now() - startedAt) / 1000),
|
|
76
|
+
timings: {
|
|
77
|
+
total,
|
|
78
|
+
errors,
|
|
79
|
+
tiers: { ...tierCounts },
|
|
80
|
+
latency_ms: sorted.length
|
|
81
|
+
? {
|
|
82
|
+
p50: percentile(sorted, 0.5),
|
|
83
|
+
p90: percentile(sorted, 0.9),
|
|
84
|
+
p99: percentile(sorted, 0.99),
|
|
85
|
+
max: Math.round(sorted[sorted.length - 1]! * 100) / 100,
|
|
86
|
+
}
|
|
87
|
+
: null,
|
|
88
|
+
latency_samples: sorted.length,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Test-only reset of all counters + the reservoir. */
|
|
94
|
+
export function resetMetricsForTest(): void {
|
|
95
|
+
latencies.length = 0
|
|
96
|
+
writeIdx = 0
|
|
97
|
+
|
|
98
|
+
for (const key of Object.keys(tierCounts)) {
|
|
99
|
+
delete tierCounts[key]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
total = 0
|
|
103
|
+
errors = 0
|
|
104
|
+
}
|
package/openapi.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* OpenAPI emit helpers. The document is always derived from the route table — never
|
|
7
|
+
* handwritten. 3.1 is the published flavor; 3.0 exists solely for client generators that lag
|
|
8
|
+
* (progenitor), replacing the old hand-downgrade step.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
12
|
+
import { dirname } from "node:path"
|
|
13
|
+
|
|
14
|
+
import type { OpenAPIHono } from "@hono/zod-openapi"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The document config stamped into emitted documents: `title`/`version`/`description`/`summary`/`license`/`contact`
|
|
18
|
+
* land under the document's `info` block; `externalDocs`/`servers`/`tags`/`security` are top-level document fields. All
|
|
19
|
+
* fields beyond `title`/`version` are optional — existing callers that only pass those two are unaffected.
|
|
20
|
+
*/
|
|
21
|
+
export interface OpenAPIDocInfo {
|
|
22
|
+
title: string
|
|
23
|
+
version: string
|
|
24
|
+
description?: string
|
|
25
|
+
summary?: string
|
|
26
|
+
license?: { name: string; identifier?: string }
|
|
27
|
+
contact?: { name?: string; url?: string }
|
|
28
|
+
externalDocs?: { description?: string; url: string }
|
|
29
|
+
servers?: Array<{
|
|
30
|
+
url: string
|
|
31
|
+
description?: string
|
|
32
|
+
variables?: Record<string, { default: string; description?: string }>
|
|
33
|
+
}>
|
|
34
|
+
tags?: Array<{ name: string; description?: string }>
|
|
35
|
+
security?: unknown[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split an `OpenAPIDocInfo` into the document's `info` block and its top-level sibling fields. */
|
|
39
|
+
function toDocumentConfig(info: OpenAPIDocInfo) {
|
|
40
|
+
const { title, version, description, summary, license, contact, externalDocs, servers, tags, security } = info
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
info: { title, version, description, summary, license, contact },
|
|
44
|
+
externalDocs,
|
|
45
|
+
servers,
|
|
46
|
+
tags,
|
|
47
|
+
security,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mount the OpenAPI 3.1 document endpoint on `app` (default `/openapi.json`). */
|
|
52
|
+
export function attachOpenAPIDocs(app: OpenAPIHono, info: OpenAPIDocInfo, path = "/openapi.json"): void {
|
|
53
|
+
// openapi3-ts's InfoObject/OpenAPIObject carry an `x-${string}` extension index signature that
|
|
54
|
+
// a plain interface can't satisfy — cast at the boundary rather than widening the public type.
|
|
55
|
+
app.doc31(path, { openapi: "3.1.0", ...toDocumentConfig(info) } as never)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Emit both document flavors programmatically (build artifacts, parity tests, client generation). */
|
|
59
|
+
export function emitOpenAPIDocuments(app: OpenAPIHono, info: OpenAPIDocInfo): { v31: object; v30: object } {
|
|
60
|
+
return {
|
|
61
|
+
v31: app.getOpenAPI31Document({ openapi: "3.1.0", ...toDocumentConfig(info) } as never),
|
|
62
|
+
v30: app.getOpenAPIDocument({ openapi: "3.0.3", ...toDocumentConfig(info) } as never),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The shared body of every surface's `openapi` CLI subcommand (the three drop-ins + `mailwoman openapi`): pick the
|
|
68
|
+
* flavor `emitOpenAPIDocuments` produces (`--flavor 3.0` → the 3.0.3 diet client generators like progenitor want;
|
|
69
|
+
* default 3.1.0), then either print it to stdout or write it to `out`. Always compact (single-line) JSON — never
|
|
70
|
+
* pretty-printed — so the stdout form is a stable `startsWith('{"openapi":"3.1.0"')` smoke check, matching what a live
|
|
71
|
+
* `/openapi.json` response looks like. `out`'s parent directory is created if missing (the docs build writes into a
|
|
72
|
+
* gitignored, not-yet-existing `docs/static/openapi/`). One place owns this so the four emitters can't drift out of
|
|
73
|
+
* lockstep with each other.
|
|
74
|
+
*/
|
|
75
|
+
export function printOpenAPIDocument(
|
|
76
|
+
app: OpenAPIHono,
|
|
77
|
+
info: OpenAPIDocInfo,
|
|
78
|
+
opts: { flavor?: string; out?: string } = {}
|
|
79
|
+
): void {
|
|
80
|
+
const { v31, v30 } = emitOpenAPIDocuments(app, info)
|
|
81
|
+
const json = JSON.stringify(opts.flavor === "3.0" ? v30 : v31)
|
|
82
|
+
|
|
83
|
+
if (opts.out) {
|
|
84
|
+
mkdirSync(dirname(opts.out), { recursive: true })
|
|
85
|
+
writeFileSync(opts.out, `${json}\n`)
|
|
86
|
+
} else {
|
|
87
|
+
console.log(json)
|
|
88
|
+
}
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/api-kit",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "API plumbing for Mailwoman's HTTP surfaces — Hono node serve wrapper, OpenAPI emit helpers, shared wire atoms. Plumbing only: domain schemas live with their routes.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -13,19 +13,33 @@
|
|
|
13
13
|
"out/**/*.js.map",
|
|
14
14
|
"out/**/*.d.ts",
|
|
15
15
|
"out/**/*.d.ts.map",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"*.ts",
|
|
18
|
+
"*.tsx",
|
|
19
|
+
"**/*.ts",
|
|
20
|
+
"**/*.tsx",
|
|
21
|
+
"!*.test.ts",
|
|
22
|
+
"!*.test.tsx",
|
|
23
|
+
"!**/*.test.ts",
|
|
24
|
+
"!**/*.test.tsx"
|
|
17
25
|
],
|
|
18
26
|
"type": "module",
|
|
19
27
|
"exports": {
|
|
20
28
|
"./package.json": "./package.json",
|
|
21
29
|
".": {
|
|
22
|
-
"
|
|
23
|
-
"default": "./out/index.js"
|
|
24
|
-
"types": "./out/index.d.ts"
|
|
30
|
+
"types": "./out/index.d.ts",
|
|
31
|
+
"default": "./out/index.js"
|
|
25
32
|
}
|
|
26
33
|
},
|
|
27
34
|
"publishConfig": {
|
|
28
|
-
"access": "public"
|
|
35
|
+
"access": "public",
|
|
36
|
+
"exports": {
|
|
37
|
+
"./package.json": "./package.json",
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./out/index.d.ts",
|
|
40
|
+
"default": "./out/index.js"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
29
43
|
},
|
|
30
44
|
"dependencies": {
|
|
31
45
|
"@hono/node-server": "^2.0.8",
|
package/serve.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Node serve wrapper over `@hono/node-server`. The one place the node listener is created —
|
|
7
|
+
* surface packages stay web-standard (they only export `fetch`-shaped apps) so an edge
|
|
8
|
+
* deployment needs no changes to them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { serve } from "@hono/node-server"
|
|
12
|
+
|
|
13
|
+
/** A `fetch`-shaped request handler (what `OpenAPIHono.fetch` provides). */
|
|
14
|
+
export type FetchLike = (request: Request, ...args: never[]) => Response | Promise<Response>
|
|
15
|
+
|
|
16
|
+
export interface ServeNodeOptions {
|
|
17
|
+
fetch: FetchLike
|
|
18
|
+
port: number
|
|
19
|
+
hostname: string
|
|
20
|
+
/** Called once the listener is bound — receives the actual port (useful with `port: 0`). */
|
|
21
|
+
onListen?: (info: { port: number; address: string }) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ServerHandle {
|
|
25
|
+
close(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Boot a node HTTP listener for a Hono app. Returns a handle whose `close()` resolves when the listener is down. */
|
|
29
|
+
export function serveNode(options: ServeNodeOptions): ServerHandle {
|
|
30
|
+
const server = serve({ fetch: options.fetch as never, port: options.port, hostname: options.hostname }, (info) =>
|
|
31
|
+
options.onListen?.({ port: info.port, address: info.address })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
close: () =>
|
|
36
|
+
new Promise<void>((resolve, reject) => {
|
|
37
|
+
server.close((error?: Error) => (error ? reject(error) : resolve()))
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
}
|