first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# `batching`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Buffers outbound log records and flushes them in batches over a single
MessagePort frame. Reducing one-frame-per-record to one-frame-per-batch
materially lowers the wire cost for workers under load, where a sustained
plugin can emit several hundred records per second. The batcher is
sink-agnostic — it invokes an `onFlush` callback supplied at construction
with a frozen snapshot of buffered records, so it composes equally with
the worker-side client's `postMessage` call and with test sinks.
A second concern handled here is backpressure. Logging is best-effort: a
slow main-side sink must never break the application by surfacing as an
exception in `log.info(...)`. The batcher caps the number of
outstanding-but-not-yet-released batches at `maxOutstandingBatches`; once
the cap is reached new records are dropped silently and an `onDrop`
callback fires so the consumer can increment the
`LOGGER_PROXY_BACKPRESSURE_DROP` counter.
## Features
- Flush triggers: max batch size (default 100), idle interval (default
50 ms), `error`/`fatal` level (immediate, but records buffered before
the error still ship in the same batch — order is preserved).
- Bounded outstanding-batch budget (default 8). Dropped records increment
a counter; the batcher never throws on `enqueue`.
- Injectable `TimeoutScheduler` for deterministic timer tests.
- `dispose()` clears the timer and drops anything still buffered.
## Usage: Basic
```ts
import { LogBatcher } from "@suaveplan/api-logger-worker-proxy/batching";
const batcher = new LogBatcher((records) => {
port.postMessage({
v: "1.0.0",
kind: "log-batch",
plugin: { name: "demo", version: "0.1.0" },
records,
});
batcher.releaseBatch();
});
batcher.enqueue({
level: "info",
msg: "hello",
ts: new Date().toISOString(),
});
```
## Usage: Advanced
Wiring the drop counter to an OTel meter and using a deterministic
scheduler in tests:
```ts
import {
LogBatcher,
type TimeoutScheduler,
} from "@suaveplan/api-logger-worker-proxy/batching";
let dropped = 0;
const fakeScheduler: TimeoutScheduler = {
set: (h) => {
queueMicrotask(h);
return null;
},
clear: () => {},
};
const batcher = new LogBatcher((records) => sink.write(records), {
maxBatchSize: 10,
flushIntervalMs: 5,
maxOutstandingBatches: 4,
onDrop: () => {
dropped++;
},
scheduler: fakeScheduler,
});
```
## API Reference
- `new LogBatcher(onFlush, options?)` — constructor.
- `enqueue(record): boolean` — `true` accepted, `false` dropped.
- `flush()` — flush whatever is buffered; idempotent.
- `releaseBatch()` — return a batch slot to the budget.
- `dispose()` — stop accepting; drop everything in the buffer.
- `droppedCount` / `flushedBatchCount` / `bufferedCount` /
`outstandingBatches` — read-only counters for tests + telemetry.
- `DEFAULT_BATCHER_OPTIONS` — the defaults exposed for re-use.
## Implementation Notes
`error` and `fatal` flush immediately AFTER being appended to the buffer
so any records queued before them ship in the same batch and the
ordering is preserved. The `outstanding` counter is decremented by
`releaseBatch()`; the worker-side client invokes that synchronously
after `postMessage` returns since `MessagePort` has no ack frame for
log batches and there is no transport-level confirmation to wait for.
Best-effort posture: every code path that could otherwise throw on a
log call (full buffer, disposed batcher) routes through `recordDrop`
instead.

View File

@@ -0,0 +1,95 @@
# `client` (worker-side)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The worker-side proxy. Exposes a `LoggerLike` surface (`debug` / `info`
/ `warn` / `error`) that worker-isolated API plugins call as if they
held a direct reference to `@suaveplan/logger`. Under the hood each
call enqueues a record on a `LogBatcher`, which flushes batches over a
`MessagePort` to the main-side server. The client is intentionally
narrow: it does no formatting, no filtering, no sampling — those
concerns belong to the main-side logger, which already owns the
configured policy.
The client also mounts a `ControlReceiver` on the same port. When the
main host calls `disable()` on its sender (during plugin shutdown), the
worker's level methods become no-ops on the next call. This avoids a
zombie-sink failure mode where the worker keeps producing records
after the main logger has been disposed.
## Features
- `LoggerLike`-conformant surface (debug/info/warn/error).
- Plugin pre-tagging via the `PluginTag` passed at construction.
- Batched send (size / idle / immediate-error triggers).
- Bounded backpressure: records drop silently and an optional
`onDrop` callback fires; logging calls never throw.
- Control-channel receiver wired on the same port.
- Optional `ClientClock` (testable timestamps) and `TraceProvider`
(OTel correlation) seams.
- `dispose()` flushes the buffer, sends a `goodbye` frame, and
releases the control receiver. Idempotent.
## Usage: Basic
```ts
import { createLoggerProxyClient } from "@suaveplan/api-logger-worker-proxy/worker-side";
const log = createLoggerProxyClient(workerPort, {
name: "my-plugin",
version: "0.1.0",
});
log.info("request handled", { route: "/health", status: 200 });
log.error("upstream failed", { code: "ECONNRESET" });
```
## Usage: Advanced
Wire an OTel trace provider and a drop counter:
```ts
import { createLoggerProxyClient } from "@suaveplan/api-logger-worker-proxy/worker-side";
import { context as otelContext, trace } from "@opentelemetry/api";
const meter = getMeter();
const droppedCounter = meter.createCounter("logger_proxy.dropped");
const log = createLoggerProxyClient(
workerPort,
{ name: "my-plugin", version: "0.1.0" },
{
trace: () => {
const span = trace.getSpan(otelContext.active());
if (span === undefined) return undefined;
const ctx = span.spanContext();
return { trace_id: ctx.traceId, span_id: ctx.spanId };
},
onDrop: () => droppedCounter.add(1),
batching: { maxBatchSize: 50, flushIntervalMs: 25 },
}
);
```
## API Reference
- `createLoggerProxyClient(port, plugin, options?): LoggerProxyClient`
— primary entry point.
- `LoggerProxyClient extends LoggerLike` — adds `flush()`,
`droppedCount`, `enabled`, and `dispose()`.
- `ClientClock` — `{ nowIso(): string }`; default uses `Date`.
- `TraceProvider` — `() => TraceFields | undefined`.
## Implementation Notes
`enqueue` short-circuits when the control state is `disabled` — no
record is created, no allocations, no batch traffic. Once `dispose()`
runs, the disposed flag flips and the next log call throws
`LOGGER_PROXY.PROXY_DISPOSED`. This is the one path where the client
throws because use-after-dispose is a programming error and silently
dropping would hide the bug. Goodbye is best-effort: if the port is
already closed the throw is swallowed (port closure mid-dispose is a
normal shutdown race).

View File

@@ -0,0 +1,83 @@
# `control-channel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bidirectional control plane: the main process tells a worker-isolated
plugin to stop or resume emitting log records. The host calls `disable`
during plugin shutdown so a worker that is still draining its work
queue does not keep producing records after the main-side sink has been
torn down (design doc §5.5 — components dispose in reverse dependency
order). Without this channel the main-side sink would either log to a
zombie sink (silent loss with no diagnostic) or panic on a closed port.
Frames are simple: `{ kind: "control", action: "disable" | "enable" }`.
There is no acknowledgement frame; the channel is fire-and-forget. The
worker maintains a shared `ControlState` object the proxy client checks
synchronously on every log call, so a `disable` received between two
calls correctly suppresses the second.
## Features
- `createControlChannelSender(port)` — returns `{ disable, enable }`
thunks usable from the main process.
- `mountControlChannelReceiver(port, options?)` — installs a listener
on the worker-side port; returns the shared `ControlState` plus a
`dispose` thunk. Tolerates malformed frames (silent drop) so a
hostile or buggy host cannot crash the worker.
- `onChange` callback fires only on real transitions, not on repeated
same-value frames.
## Usage: Basic
```ts
// Main side
import { createControlChannelSender } from "@suaveplan/api-logger-worker-proxy/control-channel";
const ctl = createControlChannelSender(mainPort);
ctl.disable();
// ... main-side sink torn down ...
worker.terminate();
```
```ts
// Worker side
import { mountControlChannelReceiver } from "@suaveplan/api-logger-worker-proxy/control-channel";
const ctl = mountControlChannelReceiver(workerPort);
// log.info() etc. read ctl.state.enabled synchronously
```
## Usage: Advanced
Observe transitions to flush in-flight batches and surface a counter:
```ts
import { mountControlChannelReceiver } from "@suaveplan/api-logger-worker-proxy/control-channel";
const ctl = mountControlChannelReceiver(port, {
onChange: (enabled) => {
if (!enabled) batcher.flush();
meter.add(`logger_proxy.control_state.${enabled ? "on" : "off"}`, 1);
},
});
```
## API Reference
- `createControlChannelSender(port: PortLike): ControlChannelSender`
— emits `{ v, kind: "control", action }` frames.
- `mountControlChannelReceiver(port, options?)` —
returns `{ state: Readonly<ControlState>, dispose }`.
- `ControlState` — `{ enabled: boolean }`. Defaults to `true` so
workers emit normally until told otherwise.
## Implementation Notes
The receiver shares the port with `goodbye` and (incidentally) with the
worker-side log emit path — frames whose `kind` is not `"control"` are
ignored so the receiver composes cleanly with other listeners on the
same port. The state object is mutated in place (not replaced) so the
proxy client can capture a stable reference at construction time and
not need to re-resolve a getter on the per-call hot path.

View File

@@ -0,0 +1,92 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable identifier registry for failures originating in
`@suaveplan/api-logger-worker-proxy`. Every public failure path that
results in a thrown `LoggerProxyError` carries one of these
`LOGGER_PROXY.*` namespaced codes so consumers can branch on the literal
value without depending on the concrete error class.
The package follows a best-effort posture by design: a logger that throws
on log calls would force application code to wrap every `log.info(...)`
in a try/catch. Instead, the proxy drops records and increments a
counter when the wire is congested. Codes here are reserved for the
narrow set of failures where dropping is not an option — invalid frame
received on the main side (must surface to surface a protocol bug), or a
caller using the worker-side proxy after `dispose()` (a programming
error, not a transient transport problem).
## Features
- `LOGGER_PROXY_CODES` — readonly record produced by
`defineCodes("LOGGER_PROXY", [...])`. Each value is the
fully-qualified string (e.g. `"LOGGER_PROXY.QUEUE_OVERFLOW"`).
- `LoggerProxyCode` — union of every fully-qualified code in the
registry; safe to use in switch exhaustiveness checks.
- `LoggerProxyError` — typed `SuaveplanError` subclass; carries a code
and the standard cause chain, redaction, and OTel hooks from
`@suaveplan/error`.
## Usage: Basic
```ts
import {
LOGGER_PROXY_CODES,
LoggerProxyError,
} from "@suaveplan/api-logger-worker-proxy/error-codes";
throw new LoggerProxyError(
LOGGER_PROXY_CODES.PROXY_DISPOSED,
"log.info called after dispose()"
);
```
## Usage: Advanced
Branching on the code rather than `instanceof` lets caller code stay
loose-coupled to this package:
```ts
import { LOGGER_PROXY_CODES } from "@suaveplan/api-logger-worker-proxy/error-codes";
try {
proxy.info("hello");
} catch (err) {
if (
err instanceof Error &&
"code" in err &&
err.code === LOGGER_PROXY_CODES.PROXY_DISPOSED
) {
// restart the proxy
} else {
throw err;
}
}
```
## API Reference
- `LOGGER_PROXY_CODES.QUEUE_OVERFLOW` — emitted main-side when an
inbound batch exceeds the configured ceiling; the inbound port is
closed to protect the main logger.
- `LOGGER_PROXY_CODES.PROXY_DISPOSED` — emitted worker-side when a log
call is made after `dispose()`.
- `LOGGER_PROXY_CODES.INVALID_FRAME` — emitted main-side when a frame
fails Zod validation.
- `LOGGER_PROXY_CODES.PORT_CLOSED` — emitted main-side when a `goodbye`
frame is received or when `postMessage` throws.
- `LOGGER_PROXY_CODES.DISABLED` — informational; the proxy is
currently in `disable` mode and is dropping records.
## Implementation Notes
`defineCodes` from `@suaveplan/error` produces a frozen record with
fully-qualified string values. The package SHOULD NOT add codes for
transient transport failures — those increment counters instead so the
application keeps running. Adding a new code is a breaking change for
downstream consumers that branch on the union; gate via OpenSpec.

View File

@@ -0,0 +1,101 @@
# `protocol`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod-validated wire format for log frames travelling from a
worker-isolated plugin to the main-process `@suaveplan/logger`. Frames
are a small discriminated union — `log-batch`, `goodbye`, and `control`
— so receivers branch on `kind` and reject malformed input cleanly via
Zod parsing rather than ad-hoc shape checks.
The protocol is bidirectional: workers send `log-batch` and `goodbye`
frames to the main, the main sends `control` frames (`disable` /
`enable`) back to the worker so it can stop emitting after the
main-side logger has been disposed (design doc §5.5 dispose-in-reverse).
## Features
- `LOGGER_PROXY_PROTOCOL_VERSION` — `"1.0.0"`. Embedded in every frame
via the `v` field; servers reject on mismatch.
- `LOG_LEVELS` / `LogLevel` — `trace | debug | info | warn | error |
fatal`. Wider than `LoggerLike` to allow forward-compatibility; the
main-side server narrows down before dispatch.
- `PluginTag` / `pluginTagSchema` — `{ name, version }` attached to
every batch.
- `WireLogRecord` / `logRecordSchema` — single record: `level`, `msg`,
`ts`, optional `ctx` map, optional `trace` (`trace_id` / `span_id`).
- `LogBatchFrame` / `logBatchFrameSchema` — `{ kind: "log-batch", v,
plugin, records: WireLogRecord[] }` with `min(1)` records.
- `ControlFrame` / `controlFrameSchema` — `{ kind: "control", v,
action: "disable" | "enable" }`.
- `parseLoggerProxyFrame(input)` — validates and returns the
discriminated union; throws `ZodError` on malformed input.
## Usage: Basic
```ts
import {
LOGGER_PROXY_PROTOCOL_VERSION,
parseLoggerProxyFrame,
} from "@suaveplan/api-logger-worker-proxy/protocol";
const frame = parseLoggerProxyFrame({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "demo", version: "0.1.0" },
records: [
{
level: "info",
msg: "hello",
ts: new Date().toISOString(),
ctx: { userId: 42 },
},
],
});
```
## Usage: Advanced
Switch over `kind` with exhaustive narrowing:
```ts
import { parseLoggerProxyFrame } from "@suaveplan/api-logger-worker-proxy/protocol";
function handle(input: unknown): void {
const frame = parseLoggerProxyFrame(input);
switch (frame.kind) {
case "log-batch":
for (const r of frame.records) dispatch(r, frame.plugin);
break;
case "control":
applyControl(frame.action);
break;
case "goodbye":
shutdown(frame.reason);
break;
}
}
```
## API Reference
- `LOG_LEVELS: readonly LogLevel[]` — exported tuple for enumeration.
- `logLevelSchema: ZodEnum<LogLevel>` — re-use in downstream schemas.
- `logRecordSchema`, `logBatchFrameSchema`, `controlFrameSchema`,
`logGoodbyeFrameSchema`, `loggerProxyFrameSchema` — Zod schemas.
- `parseLoggerProxyFrame(input: unknown): LoggerProxyFrame` — entry
point for receivers. Throws on malformed input.
## Implementation Notes
The schema accepts `ctx` as `Record<string, unknown>` to keep the
proxy transparent to the application. Main-side redaction normalises
sensitive fields before they reach the sink. `ts` is required so the
main-side stays a pure forwarder; the worker stamps wall-clock time at
record creation. Frames flow over `MessagePort` and survive
structured-clone — keeping records as plain data (no `Date`, no
classes) is intentional.

View File

@@ -0,0 +1,74 @@
# `redaction-passthrough`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lets the main-side server apply the host logger's redaction policy to
records arriving from worker-isolated plugins. Workers run in their own
runtime — they cannot reach the main process's redaction configuration —
so the only safe place to redact is at the boundary, where the
main-side server holds the policy and can normalise records before they
hit the sink. This module is the seam where that happens.
The module also ships a small `maskKeys` helper that produces a redactor
for the common case (mask a fixed list of top-level keys with a
replacement string). Callers that need richer policy — nested matching,
prefix patterns, length-preserving masks — wire `@suaveplan/logger`'s
redactor through the `Redactor` structural type.
## Features
- `applyRedactionPassthrough(record, opts)` — pure function returning a
new {@link WireLogRecord} with `ctx` and/or `msg` rewritten.
- `Redactor<T>(value: T): T` — structural type matching
`@suaveplan/logger`'s redactor surface; passes any record-shaped
value through the policy.
- `maskKeys(keys, replacement?)` — built-in fixed-key redactor.
## Usage: Basic
```ts
import {
applyRedactionPassthrough,
maskKeys,
} from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
const redact = maskKeys(["password", "token"]);
const safe = applyRedactionPassthrough(record, { ctxRedactor: redact });
mainLogger.info(safe.msg, safe.ctx);
```
## Usage: Advanced
Combine with a message-level scrubber for free-form text:
```ts
import { applyRedactionPassthrough } from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
const safe = applyRedactionPassthrough(record, {
ctxRedactor: hostLoggerRedactor, // from @suaveplan/logger
msgRedactor: (msg) => msg.replace(/\b\d{16}\b/g, "[CARD]"),
});
```
## API Reference
- `applyRedactionPassthrough(record, options)` — returns a redacted
clone. If `record.ctx` is undefined the ctx redactor is a no-op for
that record. If no redactors are configured the original record is
returned by reference (zero allocations).
- `maskKeys(keys: ReadonlyArray<string>, replacement = "[REDACTED]")` —
builds a `Redactor`. Operates only on plain-object top-level keys.
## Implementation Notes
The pass-through is intentionally narrow: it does not walk arrays or
nested objects. `@suaveplan/logger`'s full redactor handles those cases
and is what production callers should plug in. `maskKeys` is the
zero-config fallback so this package stays useful on its own (e.g. in
tests, or in callers who don't take a runtime dep on the logger's
redactor module). Input records are never mutated; a clone is produced
when redaction actually runs.

View File

@@ -0,0 +1,90 @@
# `server` (main-side)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The main-side server. Consumes inbound `log-batch` frames from a
worker-isolated plugin, applies redaction at the boundary, narrows the
wire-format levels (`trace` / `fatal`) down to the `LoggerLike` levels
(`debug` / `error`), and forwards each record to the host's main-side
`@suaveplan/logger` instance. Plugin pre-tagging is honoured by routing
through `logger.child(plugin)` when available, so every record reaches
the sink with structured `plugin` / `plugin_version` fields without
the worker having to know about the host logger's child-binding
semantics.
The server is best-effort, just like the worker-side client. Malformed
frames increment a counter, fire an `onInvalidFrame` hook, and surface
via `onError` — but they NEVER throw out of the listener. A single bad
record cannot bring down the port.
## Features
- Wire-frame validation via Zod (`parseLoggerProxyFrame`).
- Level narrowing (`trace → debug`, `fatal → error`) so downstream
sinks need not understand the broader level set.
- Optional redaction passthrough at the boundary.
- Child-logger caching per `PluginTag` (Map keyed by
`name@version` so caching survives structured-clone of the tag
object across the port boundary).
- Hard `maxBatchSize` cap (default 10_000 records/batch) that protects
the main logger from a hostile or buggy worker dumping unbounded
inbound traffic.
- Observation hooks: `onRecord` (post-redaction), `onError`, and
`onInvalidFrame`.
- `forwardedCount`, `invalidFrameCount`, `overflowDropCount` counters.
## Usage: Basic
```ts
import { createLoggerProxyServer } from "@suaveplan/api-logger-worker-proxy/main-side";
import { createLogger } from "@suaveplan/logger";
const mainLogger = createLogger({ level: "info" });
const server = createLoggerProxyServer(mainPort, mainLogger);
// ... worker emits log records over workerPort ...
server.close();
```
## Usage: Advanced
Pipe through host redaction and observe every record:
```ts
import { createLoggerProxyServer } from "@suaveplan/api-logger-worker-proxy/main-side";
import { maskKeys } from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
const server = createLoggerProxyServer(mainPort, mainLogger, {
redaction: {
ctxRedactor: maskKeys(["password", "token", "secret"]),
},
onRecord: (rec, plug) => audit.write(rec, plug),
onError: (err) => mainLogger.warn("logger-proxy error", { code: err.code }),
onInvalidFrame: (raw, cause) =>
mainLogger.warn("logger-proxy invalid frame", { raw, cause }),
maxBatchSize: 5_000,
});
```
## API Reference
- `createLoggerProxyServer(port, logger, options?): LoggerProxyServer`
— primary entry.
- `LoggerProxyServer.close()` — detach. Idempotent.
- `OnRecord(record, plugin)` — post-redaction, pre-dispatch hook.
- `OnInvalidFrame(input, error)` — fires on Zod parse failure.
## Implementation Notes
`logger.child` is optional on `LoggerLike`; when absent the server
merges `plugin` / `plugin_version` into the call-site `ctx` instead.
The child-logger cache is a `Map` keyed by `${name}@${version}` so
that workers reusing the same identity (the common case) hit the
cache even after structured-clone has produced a fresh object on the
receive side of the port. Sink
failures (the host logger throws while writing) are caught per-record
so a single bad record cannot stall the listener; the error surfaces
via `onError`.

View File

@@ -0,0 +1,72 @@
# `conformance`
## Purpose
`runLoggerProxyConformance` builds an in-process harness that exercises
the full client → port → server → main-logger pipeline using a real
`MessageChannel`. It is the recommended fixture for any test that needs
to assert end-to-end behaviour of the proxy: round-trip of every level,
plugin pre-tagging, batching behaviour, backpressure-drop accounting,
and control-channel transitions.
The harness wires a minimal `LoggerLike` whose level methods push into
a `forwarded` array, so assertions look like normal data assertions
rather than mock-based call recording. There are no mocks; every
component is the real production object.
## Features
- Real `MessageChannel`-backed port pair.
- Real client + server + control sender.
- `forwarded: ForwardedRecord[]` captures every record dispatched to
the main logger after redaction and level narrowing.
- `dispose()` tears down everything cleanly.
- `ALL_LEVELS` constant for parametric tests.
## Usage: Basic
```ts
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
const h = runLoggerProxyConformance({ name: "x", version: "0.1.0" });
h.client.info("hi");
h.client.flush();
await new Promise((r) => setTimeout(r, 5));
expect(h.forwarded[0]?.level).toBe("info");
h.dispose();
```
## Usage: Advanced
Exercise the control channel:
```ts
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
const h = runLoggerProxyConformance({ name: "x", version: "0.1.0" });
h.controlSender.disable();
await new Promise((r) => setTimeout(r, 5));
h.client.info("dropped");
h.client.flush();
await new Promise((r) => setTimeout(r, 5));
expect(h.forwarded.length).toBe(0);
h.dispose();
```
## API Reference
- `runLoggerProxyConformance(plugin, options?): ConformanceHarness`
- `ConformanceHarness.client` — `LoggerProxyClient`.
- `ConformanceHarness.server` — `LoggerProxyServer`.
- `ConformanceHarness.forwarded` — `ForwardedRecord[]`.
- `ConformanceHarness.controlSender` — `ControlChannelSender`.
- `ConformanceHarness.dispose()` — clean teardown.
## Implementation Notes
The harness's `mainLogger` does not implement `logger.child`, so every
record is forwarded with `plugin` / `plugin_version` merged into the
context map (the no-child fallback path in the server). Tests that
exercise the child-binding path supply a custom `LoggerLike` directly
via `createLoggerProxyServer`. The captured array is mutated by
push — order matches dispatch order.

View File

@@ -0,0 +1,92 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-process test fixtures for `@suaveplan/api-logger-worker-proxy`.
Provides a `MessageChannel`-backed `PortLike` pair so conformance and
unit tests can wire the worker-side client to the main-side server
without spawning a real `Worker`. Also ships a `CapturedSink`
in-memory recorder for tests that need to assert on every record that
reached the boundary, plus a `buildRecord` helper for constructing
`WireLogRecord`s with sensible defaults.
This subpath is intentionally usable from any test — including tests
inside other packages — so the conformance harness for the proxy is
reusable in downstream integration tests (e.g. a plugin author who
wants to confirm their worker correctly emits records through the
proxy).
## Features
- `createInProcessPortPair()` — paired `PortLike` ports backed by a
real `MessageChannel`. Available natively in Node 18+, Bun, and
browsers.
- `PortLike` — structural type matching both Node/Bun's
`worker_threads` `MessagePort` and the Web `MessagePort`.
- `CapturedSink` — push-based recorder; `records` for assertions,
`reset()` between cases.
- `buildRecord(level, msg, ctx?, trace?, ts?)` — composes a
`WireLogRecord` with defaults.
- `runLoggerProxyConformance(plugin, options?)` — full paired
harness: client + server + captured-output array + control sender
+ `dispose()`.
## Usage: Basic
```ts
import {
createInProcessPortPair,
runLoggerProxyConformance,
} from "@suaveplan/api-logger-worker-proxy/testing";
const harness = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
harness.client.info("hello", { route: "/health" });
harness.client.flush();
await new Promise((r) => setTimeout(r, 5));
console.log(harness.forwarded); // [{ level: "info", msg: "hello", ctx: { ... } }]
harness.dispose();
```
## Usage: Advanced
Drive `disable` / `enable` transitions and assert on backpressure:
```ts
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
const harness = runLoggerProxyConformance(
{ name: "demo", version: "0.1.0" },
{ batching: { batching: { maxOutstandingBatches: 1, maxBatchSize: 1 } } }
);
harness.controlSender.disable();
harness.client.info("ignored"); // dropped silently
harness.controlSender.enable();
harness.client.info("kept");
harness.dispose();
```
## API Reference
- `PortLike` — `{ postMessage, addEventListener, removeEventListener,
start?, close? }`.
- `InProcessPortPair` — `{ workerPort, mainPort, close }`.
- `CapturedSink` — `{ records: CapturedRecord[], push, reset }`.
- `buildRecord(level, msg, ctx?, trace?, ts?): WireLogRecord`.
- `runLoggerProxyConformance(plugin, options?): ConformanceHarness`.
- `ALL_LEVELS` — every wire-format level for parametric tests.
## Implementation Notes
`MessageChannel` is asynchronous: a `postMessage(...)` is delivered on
the next tick, so tests must `await` a `setTimeout(0)` or similar
before asserting on `forwarded`. The harness calls `client.flush()`
internally only on explicit invocation — the test author drives the
flush schedule. `dispose()` closes the channel; subsequent log calls
on the client throw `LOGGER_PROXY.PROXY_DISPOSED`.