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,98 @@
# @suaveplan/api-logger-worker-proxy - Worker → main proxy for @suaveplan/logger. Marshals LoggerLike calls across a MessagePort with plugin pre-tagging, batched send, bounded backpressure (drops rather than throws), main-side redaction passthrough, and a control channel for disable/enable during shutdown.
Stage-3 worker → main proxy for `@suaveplan/logger`. Lets a
worker-isolated API plugin call a normal `LoggerLike` surface while the
records cross a `MessagePort` to the host's main-side logger. Pre-tags
every record with the plugin's `{ name, version }`, batches outbound
traffic, drops under backpressure (no thrown exceptions on log calls),
applies redaction at the boundary, and exposes a control channel so the
main host can `disable` worker emit during shutdown.
Universal: depends only on a structural `PortLike` shape that
matches both Node/Bun's `worker_threads` `MessagePort` and the Web
platform's `MessagePort`. No platform globals at module load.
<!-- begin:auto-docs -->
## Documentation tree
- [batching](./src/batching/batching.md) — Buffers outbound log records and flushes them in batches over a single MessagePort frame. Reducing one-frame-per-record…
- [batching](./src/batching/batching.md) — Buffers outbound log records and flushes them in batches over a single MessagePort frame. Reducing one-frame-per-record…
- [client](./src/client/client.md) — The worker-side proxy. Exposes a `LoggerLike` surface (`debug` / `info` / `warn` / `error`) that worker-isolated API pl…
- [client](./src/client/client.md) — The worker-side proxy. Exposes a `LoggerLike` surface (`debug` / `info` / `warn` / `error`) that worker-isolated API pl…
- [control-channel](./src/control-channel/control-channel.md) — Bidirectional control plane: the main process tells a worker-isolated plugin to stop or resume emitting log records. Th…
- [control-channel](./src/control-channel/control-channel.md) — Bidirectional control plane: the main process tells a worker-isolated plugin to stop or resume emitting log records. Th…
- [error-codes](./src/error-codes/error-codes.md) — Stable identifier registry for failures originating in `@suaveplan/api-logger-worker-proxy`. Every public failure path…
- [error-codes](./src/error-codes/error-codes.md) — Stable identifier registry for failures originating in `@suaveplan/api-logger-worker-proxy`. Every public failure path…
- [protocol](./src/protocol/protocol.md) — Defines the Zod-validated wire format for log frames travelling from a worker-isolated plugin to the main-process `@sua…
- [protocol](./src/protocol/protocol.md) — Defines the Zod-validated wire format for log frames travelling from a worker-isolated plugin to the main-process `@sua…
- [redaction-passthrough](./src/redaction-passthrough/redaction-passthrough.md) — Lets the main-side server apply the host logger's redaction policy to records arriving from worker-isolated plugins. Wo…
- [redaction-passthrough](./src/redaction-passthrough/redaction-passthrough.md) — Lets the main-side server apply the host logger's redaction policy to records arriving from worker-isolated plugins. Wo…
- [server](./src/server/server.md) — The main-side server. Consumes inbound `log-batch` frames from a worker-isolated plugin, applies redaction at the bound…
- [server](./src/server/server.md) — The main-side server. Consumes inbound `log-batch` frames from a worker-isolated plugin, applies redaction at the bound…
- [testing](./src/testing/testing.md) — In-process test fixtures for `@suaveplan/api-logger-worker-proxy`. Provides a `MessageChannel`-backed `PortLike` pair s…
- [conformance](./src/testing/conformance.md) — `runLoggerProxyConformance` builds an in-process harness that exercises the full client → port → server → main-logger p…
- [testing](./src/testing/testing.md) — In-process test fixtures for `@suaveplan/api-logger-worker-proxy`. Provides a `MessageChannel`-backed `PortLike` pair s…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-logger-worker-proxy
```
## Quick start
```ts
// Worker side
import { createLoggerProxyClient } from "@suaveplan/api-logger-worker-proxy/worker-side";
const log = createLoggerProxyClient(workerPort, {
name: "my-plugin",
version: "0.1.0",
});
log.info("hello", { route: "/health" });
log.error("upstream failed", { code: "ECONNRESET" });
```
```ts
// Main side
import { createLoggerProxyServer } from "@suaveplan/api-logger-worker-proxy/main-side";
import { createConsoleLogger } from "@suaveplan/logger/console";
const server = createLoggerProxyServer(mainPort, createConsoleLogger());
// ... later, during shutdown:
import { createControlChannelSender } from "@suaveplan/api-logger-worker-proxy/control-channel";
createControlChannelSender(mainPort).disable();
server.close();
```
## Subpath exports
- [`./main-side`](src/server/server.md) — `createLoggerProxyServer` for the host.
- [`./worker-side`](src/client/client.md) — `createLoggerProxyClient` for the worker.
- [`./batching`](src/batching/batching.md) — size / idle / immediate-flush triggers + bounded backpressure.
- [`./control-channel`](src/control-channel/control-channel.md) — `disable` / `enable` channel.
- [`./redaction-passthrough`](src/redaction-passthrough/redaction-passthrough.md) — host-side redaction at the boundary.
- [`./protocol`](src/protocol/protocol.md) — wire format and Zod schemas.
- [`./error-codes`](src/error-codes/error-codes.md) — `LOGGER_PROXY.*` codes.
- [`./testing`](src/testing/testing.md) — in-process harness and conformance helpers.
## Design notes
- **Best-effort logging.** `log.info(...)` and friends NEVER throw on a
full queue or a closed port. Dropped records bump a counter that
consumers may surface via OTel; an application MUST NOT break because
the log sink is slow.
- **Plugin pre-tagging.** Workers attach `{ name, version }` to each
batch; the main-side server routes through `logger.child(plugin)`
when the host logger supports it, otherwise merges into context.
- **Disposal order.** During plugin shutdown the main host calls
`disable()` BEFORE tearing down its sink. The worker stops emitting,
the sink is disposed, then the worker is terminated.
- **No mocks.** The conformance harness wires real production objects
through a real `MessageChannel` — see `src/testing/conformance.md`.
## License
MIT

View File

@@ -0,0 +1,112 @@
{
"name": "@suaveplan/api-logger-worker-proxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Worker → main proxy for @suaveplan/logger. Marshals LoggerLike calls across a MessagePort with plugin pre-tagging, batched send, bounded backpressure (drops rather than throws), main-side redaction passthrough, and a control channel for disable/enable during shutdown.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./protocol": {
"import": {
"types": "./dist/protocol/protocol.d.ts",
"default": "./dist/protocol/protocol.js"
}
},
"./worker-side": {
"import": {
"types": "./dist/client/client.d.ts",
"default": "./dist/client/client.js"
}
},
"./main-side": {
"import": {
"types": "./dist/server/server.d.ts",
"default": "./dist/server/server.js"
}
},
"./batching": {
"import": {
"types": "./dist/batching/batching.d.ts",
"default": "./dist/batching/batching.js"
}
},
"./control-channel": {
"import": {
"types": "./dist/control-channel/control-channel.d.ts",
"default": "./dist/control-channel/control-channel.js"
}
},
"./redaction-passthrough": {
"import": {
"types": "./dist/redaction-passthrough/redaction-passthrough.d.ts",
"default": "./dist/redaction-passthrough/redaction-passthrough.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,255 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { WireLogRecord } from "../protocol/protocol.js";
import {
DEFAULT_BATCHER_OPTIONS,
LogBatcher,
type TimeoutScheduler,
} from "./batching.js";
function rec(level: WireLogRecord["level"] = "info", msg = "x"): WireLogRecord {
return { level, msg, ts: "2026-05-25T00:00:00.000Z" };
}
function microtaskScheduler(): TimeoutScheduler {
return {
set: (handler) => {
queueMicrotask(handler);
return Symbol("handle");
},
clear: () => {},
};
}
describe("LogBatcher", () => {
it("exposes defaults", { timeout: 5000 }, () => {
expect(DEFAULT_BATCHER_OPTIONS.maxBatchSize).toBe(100);
expect(DEFAULT_BATCHER_OPTIONS.flushIntervalMs).toBe(50);
expect(DEFAULT_BATCHER_OPTIONS.maxOutstandingBatches).toBe(8);
});
it("flushes on maxBatchSize", { timeout: 5000 }, () => {
const flushed: WireLogRecord[][] = [];
const b = new LogBatcher(
(rs) => {
flushed.push(rs.slice());
b.releaseBatch();
},
{ maxBatchSize: 3, flushIntervalMs: 60000 }
);
for (let i = 0; i < 3; i++) b.enqueue(rec("info", `m${i}`));
expect(flushed.length).toBe(1);
expect(flushed[0]?.length).toBe(3);
expect(b.bufferedCount).toBe(0);
});
it("flushes immediately on error level AND preserves earlier records", {
timeout: 5000,
}, () => {
const flushed: WireLogRecord[][] = [];
const b = new LogBatcher(
(rs) => {
flushed.push(rs.slice());
b.releaseBatch();
},
{ maxBatchSize: 100, flushIntervalMs: 60000 }
);
for (let i = 0; i < 5; i++) b.enqueue(rec("info", `m${i}`));
b.enqueue(rec("error", "boom"));
expect(flushed.length).toBe(1);
expect(flushed[0]?.length).toBe(6);
expect(flushed[0]?.[5]?.msg).toBe("boom");
});
it("flushes immediately on fatal level", { timeout: 5000 }, () => {
const flushed: WireLogRecord[][] = [];
const b = new LogBatcher(
(rs) => {
flushed.push(rs.slice());
b.releaseBatch();
},
{ maxBatchSize: 100, flushIntervalMs: 60000 }
);
b.enqueue(rec("fatal", "down"));
expect(flushed.length).toBe(1);
});
it("flushes after the idle interval via injected scheduler", {
timeout: 5000,
}, async () => {
let fired = 0;
const b = new LogBatcher(
(rs) => {
fired += rs.length;
b.releaseBatch();
},
{
maxBatchSize: 100,
flushIntervalMs: 5,
scheduler: microtaskScheduler(),
}
);
b.enqueue(rec("info", "a"));
await new Promise((r) => queueMicrotask(() => r(undefined)));
expect(fired).toBe(1);
});
it("does not reschedule timer when one is already pending", {
timeout: 5000,
}, async () => {
let setCount = 0;
const sched: TimeoutScheduler = {
set: (h) => {
setCount++;
queueMicrotask(h);
return Symbol("h");
},
clear: () => {},
};
const b = new LogBatcher(
(_rs) => {
b.releaseBatch();
},
{ maxBatchSize: 100, flushIntervalMs: 5, scheduler: sched }
);
b.enqueue(rec("info", "a"));
b.enqueue(rec("info", "b"));
expect(setCount).toBe(1);
await new Promise((r) => queueMicrotask(() => r(undefined)));
});
it("drops records and increments counter when outstanding budget is full", {
timeout: 5000,
}, () => {
const dropped: WireLogRecord[] = [];
const b = new LogBatcher(
(_rs) => {
// never releaseBatch — simulate slow main side
},
{
maxBatchSize: 1,
flushIntervalMs: 60000,
maxOutstandingBatches: 2,
onDrop: (r) => dropped.push(r),
}
);
// Two batches consumed by the un-released sink, then drops.
for (let i = 0; i < 10; i++) {
expect(() => b.enqueue(rec("info", `m${i}`))).not.toThrow();
}
expect(b.droppedCount).toBeGreaterThan(0);
expect(dropped.length).toBe(b.droppedCount);
});
it("exposes outstandingBatches counter and decrements on release", {
timeout: 5000,
}, () => {
let pending: (() => void)[] = [];
const b = new LogBatcher(
(_rs) => {
pending.push(() => b.releaseBatch());
},
{ maxBatchSize: 1, flushIntervalMs: 60000 }
);
b.enqueue(rec("info"));
b.enqueue(rec("info"));
expect(b.outstandingBatches).toBe(2);
pending.forEach((f) => f());
pending = [];
expect(b.outstandingBatches).toBe(0);
});
it("flush is idempotent on an empty buffer", { timeout: 5000 }, () => {
let calls = 0;
const b = new LogBatcher(
(_rs) => {
calls++;
b.releaseBatch();
},
{ maxBatchSize: 100, flushIntervalMs: 60000 }
);
b.flush();
b.flush();
expect(calls).toBe(0);
});
it("tracks flushedBatchCount", { timeout: 5000 }, () => {
const b = new LogBatcher(
(_rs) => {
b.releaseBatch();
},
{ maxBatchSize: 1, flushIntervalMs: 60000 }
);
b.enqueue(rec("info"));
b.enqueue(rec("info"));
expect(b.flushedBatchCount).toBe(2);
});
it("dispose drops buffered records and ignores subsequent enqueues", {
timeout: 5000,
}, () => {
const dropped: WireLogRecord[] = [];
const b = new LogBatcher(
(_rs) => {
b.releaseBatch();
},
{
maxBatchSize: 100,
flushIntervalMs: 60000,
onDrop: (r) => dropped.push(r),
}
);
b.enqueue(rec("info", "a"));
b.enqueue(rec("info", "b"));
b.dispose();
expect(dropped.length).toBe(2);
expect(b.enqueue(rec("info", "c"))).toBe(false);
expect(dropped.length).toBe(3);
b.dispose(); // idempotent
});
it("releaseBatch is safe to call when outstanding is 0", {
timeout: 5000,
}, () => {
const b = new LogBatcher((_rs) => {});
expect(() => b.releaseBatch()).not.toThrow();
});
it("dispose with no buffer is a no-op", { timeout: 5000 }, () => {
const b = new LogBatcher((_rs) => {});
expect(() => b.dispose()).not.toThrow();
});
it("uses real setTimeout scheduler when none supplied", {
timeout: 5000,
}, async () => {
let fired = 0;
const b = new LogBatcher(
(rs) => {
fired += rs.length;
b.releaseBatch();
},
{ maxBatchSize: 100, flushIntervalMs: 2 }
);
b.enqueue(rec("info"));
await new Promise((r) => setTimeout(r, 30));
expect(fired).toBe(1);
});
it("clears the pending timer on dispose", { timeout: 5000 }, () => {
let cleared = 0;
const sched: TimeoutScheduler = {
set: (_h) => Symbol("h"),
clear: () => {
cleared++;
},
};
const b = new LogBatcher((_rs) => {}, {
flushIntervalMs: 1000,
scheduler: sched,
});
b.enqueue(rec("info"));
b.dispose();
expect(cleared).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,203 @@
import type { LogLevel, WireLogRecord } from "../protocol/protocol.js";
/**
* Optional callback fired every time `enqueue` drops a record because the
* outstanding-batch budget is exhausted. Use it to surface the
* `LOGGER_PROXY_BACKPRESSURE_DROP` counter to OTel or to a fallback sink
* (e.g. `console.warn`) without throwing.
*/
export type BatcherDropCallback = (record: WireLogRecord) => void;
/**
* Batcher configuration. Defaults are tuned for the foundation design
* intent: `flushIntervalMs=50` keeps records timely, `maxBatchSize=100`
* caps per-frame size, `maxOutstandingBatches=8` is enough to absorb
* brief main-side stalls without unbounded growth.
*/
export interface BatcherOptions {
readonly maxBatchSize?: number;
readonly flushIntervalMs?: number;
readonly maxOutstandingBatches?: number;
readonly onDrop?: BatcherDropCallback;
/**
* Injected scheduler. Defaults to `setTimeout`/`clearTimeout`. Tests
* pass deterministic clocks via this seam.
*/
readonly scheduler?: TimeoutScheduler;
}
/** Pluggable scheduler so tests can drive the flush timer deterministically. */
export interface TimeoutScheduler {
set(handler: () => void, ms: number): unknown;
clear(handle: unknown): void;
}
const defaultScheduler: TimeoutScheduler = {
set: (h, ms) =>
setTimeout(h, ms) as unknown as ReturnType<typeof setTimeout>,
clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
};
export const DEFAULT_BATCHER_OPTIONS: Required<
Pick<
BatcherOptions,
"maxBatchSize" | "flushIntervalMs" | "maxOutstandingBatches"
>
> = {
maxBatchSize: 100,
flushIntervalMs: 50,
maxOutstandingBatches: 8,
};
/**
* Buffer for outbound log records with size, idle, and immediate-flush
* triggers. The batcher is sink-agnostic — `flush` invokes the
* `onFlush` callback supplied at construction with a frozen snapshot
* of the buffered records and an opaque batch handle. Callers MUST
* invoke `releaseBatch(handle)` once the transport confirms delivery
* (or fails) so the outstanding-batch budget can be returned.
*
* Backpressure: when `outstandingBatches >= maxOutstandingBatches`, new
* records are dropped (best-effort logging) and `onDrop` is invoked.
* The batcher never throws on `enqueue`.
*/
export class LogBatcher {
private readonly buffer: WireLogRecord[] = [];
private readonly opts: Required<
Pick<
BatcherOptions,
"maxBatchSize" | "flushIntervalMs" | "maxOutstandingBatches"
>
>;
private readonly scheduler: TimeoutScheduler;
private readonly onFlush: (records: ReadonlyArray<WireLogRecord>) => void;
private readonly onDrop: BatcherDropCallback | undefined;
private outstanding = 0;
private timer: unknown = undefined;
private dropped = 0;
private flushedBatches = 0;
private disposed = false;
public constructor(
onFlush: (records: ReadonlyArray<WireLogRecord>) => void,
options: BatcherOptions = {}
) {
this.onFlush = onFlush;
this.opts = {
maxBatchSize:
options.maxBatchSize ?? DEFAULT_BATCHER_OPTIONS.maxBatchSize,
flushIntervalMs:
options.flushIntervalMs ??
DEFAULT_BATCHER_OPTIONS.flushIntervalMs,
maxOutstandingBatches:
options.maxOutstandingBatches ??
DEFAULT_BATCHER_OPTIONS.maxOutstandingBatches,
};
this.scheduler = options.scheduler ?? defaultScheduler;
this.onDrop = options.onDrop;
}
public get droppedCount(): number {
return this.dropped;
}
public get flushedBatchCount(): number {
return this.flushedBatches;
}
public get bufferedCount(): number {
return this.buffer.length;
}
public get outstandingBatches(): number {
return this.outstanding;
}
/**
* Enqueue a record. Returns `true` when accepted, `false` when dropped
* due to backpressure or disposal. `error`/`fatal` records trigger an
* immediate flush after being appended (so they DO ship together with
* any earlier buffered records).
*/
public enqueue(record: WireLogRecord): boolean {
if (this.disposed) {
this.recordDrop(record);
return false;
}
if (this.outstanding >= this.opts.maxOutstandingBatches) {
this.recordDrop(record);
return false;
}
this.buffer.push(record);
if (this.shouldImmediateFlush(record.level)) {
this.flush();
return true;
}
if (this.buffer.length >= this.opts.maxBatchSize) {
this.flush();
return true;
}
this.ensureTimer();
return true;
}
/** Flush whatever is buffered. Idempotent on an empty buffer. */
public flush(): void {
this.clearTimer();
if (this.buffer.length === 0) return;
const snapshot = this.buffer.slice();
this.buffer.length = 0;
this.outstanding++;
this.flushedBatches++;
try {
this.onFlush(snapshot);
} finally {
// `releaseBatch` is invoked by the transport seam after delivery
// acknowledgement; if the consumer chose not to wire that seam
// we still need to release synchronously so the budget does not
// monotonically drain. `releaseBatch` itself is idempotent.
}
}
/**
* Release a previously-flushed batch slot. Called by the transport
* (worker-side client) after `postMessage` returns, since `MessagePort`
* delivers synchronously and there is no ack frame for log batches.
*/
public releaseBatch(): void {
if (this.outstanding > 0) this.outstanding--;
}
/** Stop accepting records and clear the timer. Buffered records are dropped. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.clearTimer();
for (const r of this.buffer) this.recordDrop(r);
this.buffer.length = 0;
}
private shouldImmediateFlush(level: LogLevel): boolean {
return level === "error" || level === "fatal";
}
private ensureTimer(): void {
if (this.timer !== undefined) return;
this.timer = this.scheduler.set(() => {
this.timer = undefined;
this.flush();
}, this.opts.flushIntervalMs);
}
private clearTimer(): void {
if (this.timer !== undefined) {
this.scheduler.clear(this.timer);
this.timer = undefined;
}
}
private recordDrop(record: WireLogRecord): void {
this.dropped++;
this.onDrop?.(record);
}
}

View File

@@ -0,0 +1,7 @@
export {
type BatcherDropCallback,
type BatcherOptions,
DEFAULT_BATCHER_OPTIONS,
LogBatcher,
type TimeoutScheduler,
} from "./batching.js";

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,247 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LOGGER_PROXY_CODES,
LoggerProxyError,
} from "../error-codes/error-codes.js";
import { runLoggerProxyConformance } from "../testing/conformance.js";
import { createInProcessPortPair } from "../testing/testing.js";
import { createLoggerProxyClient } from "./client.js";
const wait = (ms = 15): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe("createLoggerProxyClient", () => {
it("round-trips every level with plugin tags", {
timeout: 5000,
}, async () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
h.client.debug("a");
h.client.info("b");
h.client.warn("c");
h.client.error("d");
await wait();
expect(h.forwarded.map((r) => r.level)).toEqual([
"debug",
"info",
"warn",
"error",
]);
expect(h.forwarded.map((r) => r.msg)).toEqual(["a", "b", "c", "d"]);
for (const r of h.forwarded) {
expect(r.ctx.plugin).toBe("demo");
expect(r.ctx.plugin_version).toBe("0.1.0");
}
h.dispose();
});
it("merges ctx and propagates trace fields", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const { createLoggerProxyServer } = await import("../server/server.js");
const captured: Array<{ msg: string; ctx: Record<string, unknown> }> =
[];
const logger = {
debug: () => {},
info: (msg: string, ctx?: Readonly<Record<string, unknown>>) =>
captured.push({ msg, ctx: ctx ?? {} }),
warn: () => {},
error: () => {},
};
const server = createLoggerProxyServer(pair.mainPort, logger);
const client = createLoggerProxyClient(
pair.workerPort,
{ name: "p", version: "1.0.0" },
{ trace: () => ({ trace_id: "t1", span_id: "s1" }) }
);
client.info("hello", { userId: 5 });
client.flush();
await wait();
expect(captured[0]?.ctx.userId).toBe(5);
expect(captured[0]?.ctx.trace_id).toBe("t1");
expect(captured[0]?.ctx.span_id).toBe("s1");
client.dispose();
server.close();
pair.close();
});
it("is a no-op while control state is disabled", {
timeout: 5000,
}, async () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
h.controlSender.disable();
await wait();
h.client.info("dropped");
h.client.flush();
await wait();
expect(h.forwarded.length).toBe(0);
h.controlSender.enable();
await wait();
h.client.info("kept");
h.client.flush();
await wait();
expect(h.forwarded.length).toBe(1);
h.dispose();
});
it("drops records under backpressure WITHOUT throwing", {
timeout: 5000,
}, async () => {
// Server never reads — point client to a sink that swallows
// postMessage outputs slowly. We accomplish this by giving the
// client a port whose peer is not connected to a server.
const pair = createInProcessPortPair();
let dropped = 0;
// Hold maxOutstandingBatches=1 and never release so the budget
// is exhausted after the first batch.
const client = createLoggerProxyClient(
pair.workerPort,
{ name: "p", version: "0.1.0" },
{
batching: {
maxBatchSize: 1,
flushIntervalMs: 1000,
maxOutstandingBatches: 1,
},
onDrop: () => {
dropped++;
},
}
);
for (let i = 0; i < 1000; i++) {
expect(() => client.info(`m${i}`)).not.toThrow();
}
expect(dropped).toBeGreaterThan(0);
expect(client.droppedCount).toBe(dropped);
client.dispose();
pair.close();
});
it("dispose flushes the buffer and sends a goodbye frame", {
timeout: 5000,
}, async () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
h.client.info("about-to-dispose");
h.client.dispose();
await wait();
expect(h.forwarded.some((r) => r.msg === "about-to-dispose")).toBe(
true
);
h.server.close();
});
it("throws LOGGER_PROXY.PROXY_DISPOSED on log after dispose", {
timeout: 5000,
}, () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
h.client.dispose();
try {
h.client.info("bad");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(LoggerProxyError);
expect((err as LoggerProxyError).code).toBe(
LOGGER_PROXY_CODES.PROXY_DISPOSED
);
}
// dispose is idempotent
h.client.dispose();
h.server.close();
});
it("dispose swallows goodbye failures when the port is already closed", {
timeout: 5000,
}, () => {
// Build a port that throws on postMessage to simulate a closed port.
const closedPort = {
postMessage: () => {
throw new Error("closed");
},
addEventListener: () => {},
removeEventListener: () => {},
};
const client = createLoggerProxyClient(closedPort, {
name: "p",
version: "0.1.0",
});
expect(() => client.dispose()).not.toThrow();
});
it("reflects enabled state via the getter", { timeout: 5000 }, async () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
expect(h.client.enabled).toBe(true);
h.controlSender.disable();
await wait();
expect(h.client.enabled).toBe(false);
h.dispose();
});
it("respects batching.onDrop AND client onDrop", { timeout: 5000 }, () => {
const pair = createInProcessPortPair();
let outer = 0;
let inner = 0;
const client = createLoggerProxyClient(
pair.workerPort,
{ name: "p", version: "0.1.0" },
{
batching: {
maxBatchSize: 1,
flushIntervalMs: 60000,
maxOutstandingBatches: 1,
onDrop: () => {
inner++;
},
},
onDrop: () => {
outer++;
},
}
);
for (let i = 0; i < 100; i++) client.info(`x${i}`);
expect(outer).toBeGreaterThan(0);
expect(inner).toBe(outer);
client.dispose();
pair.close();
});
it("uses the injected ClientClock", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const { createLoggerProxyServer } = await import("../server/server.js");
const captured: Array<{ ctx: Record<string, unknown> }> = [];
const logger = {
debug: () => {},
info: (_msg: string, ctx?: Readonly<Record<string, unknown>>) =>
captured.push({ ctx: ctx ?? {} }),
warn: () => {},
error: () => {},
};
const server = createLoggerProxyServer(pair.mainPort, logger);
const client = createLoggerProxyClient(
pair.workerPort,
{ name: "p", version: "0.1.0" },
{ clock: { nowIso: () => "2099-01-01T00:00:00.000Z" } }
);
client.info("ts-test");
client.flush();
await wait();
expect(captured[0]?.ctx.ts).toBe("2099-01-01T00:00:00.000Z");
client.dispose();
server.close();
pair.close();
});
});

View File

@@ -0,0 +1,176 @@
import type { LoggerLike } from "@suaveplan/types";
import { type BatcherOptions, LogBatcher } from "../batching/batching.js";
import {
type ControlReceiver,
mountControlChannelReceiver,
} from "../control-channel/control-channel.js";
import {
LOGGER_PROXY_CODES,
LoggerProxyError,
} from "../error-codes/error-codes.js";
import {
LOGGER_PROXY_PROTOCOL_VERSION,
type LogBatchFrame,
type LogLevel,
type PluginTag,
type TraceFields,
type WireLogRecord,
} from "../protocol/protocol.js";
import type { PortLike } from "../testing/testing.js";
/**
* Optional source of wall-clock timestamps. Defaults to `Date`. Tests
* inject a deterministic clock to make timestamps assertable.
*/
export interface ClientClock {
nowIso(): string;
}
const defaultClock: ClientClock = {
nowIso: () => new Date().toISOString(),
};
/**
* Optional source of current trace/span IDs. Returns the active fields
* to attach to each record; `undefined` keeps the record untagged.
* Workers running inside an OTel span pass an adapter here so the
* main-side server can correlate worker records with the parent span.
*/
export type TraceProvider = () => TraceFields | undefined;
export interface LoggerProxyClientOptions {
readonly clock?: ClientClock;
readonly trace?: TraceProvider;
readonly batching?: BatcherOptions;
/**
* Invoked when the batcher drops a record because the
* outstanding-batch budget is exhausted. Use it to increment the
* `LOGGER_PROXY_BACKPRESSURE_DROP` counter on an OTel meter.
*/
readonly onDrop?: (record: WireLogRecord) => void;
}
export interface LoggerProxyClient extends LoggerLike {
/** Flush whatever is buffered. */
flush(): void;
/** Return the count of records dropped under backpressure. */
readonly droppedCount: number;
/** Indicate whether the worker is currently emitting (control state). */
readonly enabled: boolean;
/**
* Stop accepting records, flush the buffer, send a goodbye frame, and
* release listeners. Idempotent.
*/
dispose(): void;
}
/**
* Build the worker-side proxy client. Returns a {@link LoggerLike}
* surface; every level method enqueues a record on the batcher and
* returns synchronously. Records carry the supplied {@link PluginTag}
* so the main-side server does not need to re-attach plugin metadata.
*
* The client also mounts a {@link ControlReceiver} on the same port so
* the main host can `disable`/`enable` worker emit during plugin
* shutdown. While disabled, every level method becomes a no-op
* (records are discarded without buffer or batch).
*
* The client never throws on a log call. The single exception is
* `dispose()` followed by a log call: that surfaces as
* `LOGGER_PROXY.PROXY_DISPOSED` because it indicates a programming
* error (use-after-free) rather than a transient transport problem.
*/
export function createLoggerProxyClient(
port: PortLike,
plugin: PluginTag,
options: LoggerProxyClientOptions = {}
): LoggerProxyClient {
const clock = options.clock ?? defaultClock;
const traceProvider = options.trace;
let disposed = false;
const batcher = new LogBatcher(
(records) => {
const frame: LogBatchFrame = {
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin,
records: records.slice(),
};
try {
port.postMessage(frame);
} finally {
// Release the slot on the next microtask so a synchronous
// burst of records can saturate the outstanding-batch budget
// and trigger backpressure drops. `postMessage` is queued
// synchronously by the platform; the slot conceptually
// releases once the host has picked up the message, which
// we approximate with a microtask boundary.
queueMicrotask(() => batcher.releaseBatch());
}
},
{
...options.batching,
onDrop: (record) => {
options.onDrop?.(record);
options.batching?.onDrop?.(record);
},
}
);
const control: ControlReceiver = mountControlChannelReceiver(port);
const enqueue = (
level: LogLevel,
msg: string,
ctx?: Readonly<Record<string, unknown>>
): void => {
if (disposed) {
throw new LoggerProxyError(
LOGGER_PROXY_CODES.PROXY_DISPOSED,
`log.${level} called after dispose()`
);
}
if (!control.state.enabled) return;
const trace = traceProvider?.();
const record: WireLogRecord = {
level,
msg,
ts: clock.nowIso(),
...(ctx !== undefined ? { ctx } : {}),
...(trace !== undefined ? { trace } : {}),
};
batcher.enqueue(record);
};
const client: LoggerProxyClient = {
debug: (msg, ctx) => enqueue("debug", msg, ctx),
info: (msg, ctx) => enqueue("info", msg, ctx),
warn: (msg, ctx) => enqueue("warn", msg, ctx),
error: (msg, ctx) => enqueue("error", msg, ctx),
flush: () => batcher.flush(),
get droppedCount(): number {
return batcher.droppedCount;
},
get enabled(): boolean {
return control.state.enabled;
},
dispose(): void {
if (disposed) return;
disposed = true;
batcher.flush();
batcher.dispose();
try {
port.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "goodbye",
reason: "client-dispose",
});
} catch {
// Best-effort goodbye; port may already be closed.
}
control.dispose();
},
};
return client;
}

View File

@@ -0,0 +1,7 @@
export {
type ClientClock,
createLoggerProxyClient,
type LoggerProxyClient,
type LoggerProxyClientOptions,
type TraceProvider,
} from "./client.js";

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,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInProcessPortPair } from "../testing/testing.js";
import {
createControlChannelSender,
mountControlChannelReceiver,
} from "./control-channel.js";
const wait = (ms = 10): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe("control-channel", () => {
it("sends disable / enable frames and flips the worker state", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const transitions: boolean[] = [];
const recv = mountControlChannelReceiver(pair.workerPort, {
onChange: (e) => transitions.push(e),
});
const send = createControlChannelSender(pair.mainPort);
expect(recv.state.enabled).toBe(true);
send.disable();
await wait();
expect(recv.state.enabled).toBe(false);
send.enable();
await wait();
expect(recv.state.enabled).toBe(true);
expect(transitions).toEqual([false, true]);
recv.dispose();
pair.close();
});
it("ignores duplicate frames of the same action", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const transitions: boolean[] = [];
const recv = mountControlChannelReceiver(pair.workerPort, {
onChange: (e) => transitions.push(e),
});
const send = createControlChannelSender(pair.mainPort);
send.enable();
send.enable();
send.enable();
await wait();
expect(transitions).toEqual([]);
recv.dispose();
pair.close();
});
it("ignores malformed and non-control frames", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const recv = mountControlChannelReceiver(pair.workerPort);
// Send a non-control frame
pair.mainPort.postMessage({ v: "1.0.0", kind: "goodbye", reason: "x" });
// null/primitives
pair.mainPort.postMessage(null);
pair.mainPort.postMessage(42);
// control with bad action
pair.mainPort.postMessage({
v: "1.0.0",
kind: "control",
action: "lol",
});
await wait();
expect(recv.state.enabled).toBe(true);
recv.dispose();
pair.close();
});
it("dispose detaches the listener", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const transitions: boolean[] = [];
const recv = mountControlChannelReceiver(pair.workerPort, {
onChange: (e) => transitions.push(e),
});
recv.dispose();
const send = createControlChannelSender(pair.mainPort);
send.disable();
await wait();
expect(transitions).toEqual([]);
// state preserved at last value (true) since handler never fired
expect(recv.state.enabled).toBe(true);
pair.close();
});
it("onChange is optional", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const recv = mountControlChannelReceiver(pair.workerPort);
const send = createControlChannelSender(pair.mainPort);
send.disable();
await wait();
expect(recv.state.enabled).toBe(false);
recv.dispose();
pair.close();
});
});

View File

@@ -0,0 +1,92 @@
import {
type ControlFrame,
LOGGER_PROXY_PROTOCOL_VERSION,
} from "../protocol/protocol.js";
import type { PortLike } from "../testing/testing.js";
/**
* Build a control-channel sender for use main-side. Returns
* `disable()` and `enable()` thunks that post the corresponding
* frame over the port. The sender is fire-and-forget; the worker MAY
* acknowledge by adjusting its emit behaviour, but no ack frame is
* defined.
*/
export interface ControlChannelSender {
disable(): void;
enable(): void;
}
export function createControlChannelSender(
port: PortLike
): ControlChannelSender {
const send = (action: "disable" | "enable"): void => {
const frame: ControlFrame = {
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "control",
action,
};
port.postMessage(frame);
};
return {
disable: () => send("disable"),
enable: () => send("enable"),
};
}
/**
* State holder mutated by the worker-side receiver. The proxy client
* consults `enabled` synchronously on every log call so a `disable`
* frame received between two `log.info(...)` calls correctly suppresses
* the second.
*/
export interface ControlState {
enabled: boolean;
}
export interface ControlReceiverOptions {
/** Fires when the worker observes a control transition. */
readonly onChange?: (enabled: boolean) => void;
}
export interface ControlReceiver {
readonly state: Readonly<ControlState>;
dispose(): void;
}
/**
* Mount a control-frame receiver on a worker-side port. Returns the
* shared mutable {@link ControlState} so the client can read
* `state.enabled` synchronously on every log call. The receiver
* ignores non-control frames (it shares the port with `goodbye` /
* incidental traffic), and is tolerant of malformed frames — invalid
* inputs are silently dropped so a malformed control frame never
* breaks the worker.
*/
export function mountControlChannelReceiver(
port: PortLike,
options: ControlReceiverOptions = {}
): ControlReceiver {
const state: ControlState = { enabled: true };
const listener = (event: { data: unknown }): void => {
const data = event.data;
if (
data === null ||
typeof data !== "object" ||
(data as { kind?: unknown }).kind !== "control"
) {
return;
}
const action = (data as { action?: unknown }).action;
if (action !== "disable" && action !== "enable") return;
const next = action === "enable";
if (state.enabled === next) return;
state.enabled = next;
options.onChange?.(state.enabled);
};
port.addEventListener("message", listener);
port.start?.();
return {
state,
dispose: () => port.removeEventListener("message", listener),
};
}

View File

@@ -0,0 +1,8 @@
export {
type ControlChannelSender,
type ControlReceiver,
type ControlReceiverOptions,
type ControlState,
createControlChannelSender,
mountControlChannelReceiver,
} from "./control-channel.js";

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,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOGGER_PROXY_CODES, LoggerProxyError } from "./error-codes.js";
describe("error-codes", () => {
it("registers all expected LOGGER_PROXY.* codes", { timeout: 5000 }, () => {
expect(LOGGER_PROXY_CODES.QUEUE_OVERFLOW).toBe(
"LOGGER_PROXY.QUEUE_OVERFLOW"
);
expect(LOGGER_PROXY_CODES.PROXY_DISPOSED).toBe(
"LOGGER_PROXY.PROXY_DISPOSED"
);
expect(LOGGER_PROXY_CODES.INVALID_FRAME).toBe(
"LOGGER_PROXY.INVALID_FRAME"
);
expect(LOGGER_PROXY_CODES.PORT_CLOSED).toBe("LOGGER_PROXY.PORT_CLOSED");
expect(LOGGER_PROXY_CODES.DISABLED).toBe("LOGGER_PROXY.DISABLED");
});
it("constructs a LoggerProxyError with name + code", {
timeout: 5000,
}, () => {
const err = new LoggerProxyError(
LOGGER_PROXY_CODES.PROXY_DISPOSED,
"test"
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("LoggerProxyError");
expect(err.code).toBe("LOGGER_PROXY.PROXY_DISPOSED");
expect(err.message).toBe("test");
});
it("supports cause chain via options", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new LoggerProxyError(
LOGGER_PROXY_CODES.INVALID_FRAME,
"outer",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,34 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-logger-worker-proxy`.
*
* Logging is best-effort: most failure modes (queue overflow, port closed,
* dispatch failure) result in a dropped record and an incremented counter
* rather than a thrown error. These codes are reserved for the cases where
* a thrown error is unavoidable — invalid frame received main-side, or a
* caller mis-uses the proxy after disposal.
*/
export const LOGGER_PROXY_CODES = defineCodes("LOGGER_PROXY", [
"QUEUE_OVERFLOW",
"PROXY_DISPOSED",
"INVALID_FRAME",
"PORT_CLOSED",
"DISABLED",
] as const);
export type LoggerProxyCode =
(typeof LOGGER_PROXY_CODES)[keyof typeof LOGGER_PROXY_CODES];
/** Typed error class for `@suaveplan/api-logger-worker-proxy`. */
export class LoggerProxyError extends SuaveplanError<LoggerProxyCode> {
public constructor(
code: LoggerProxyCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "LoggerProxyError";
}
}

View File

@@ -0,0 +1,5 @@
export {
LOGGER_PROXY_CODES,
type LoggerProxyCode,
LoggerProxyError,
} from "./error-codes.js";

View File

@@ -0,0 +1,33 @@
export * as batching from "./batching/index.js";
export {
createLoggerProxyClient,
type LoggerProxyClient,
type LoggerProxyClientOptions,
} from "./client/client.js";
export * as client from "./client/index.js";
export {
type ControlChannelSender,
createControlChannelSender,
} from "./control-channel/control-channel.js";
export * as controlChannel from "./control-channel/index.js";
export {
LOGGER_PROXY_CODES,
type LoggerProxyCode,
LoggerProxyError,
} from "./error-codes/error-codes.js";
export * as errorCodes from "./error-codes/index.js";
export * as protocol from "./protocol/index.js";
export {
LOGGER_PROXY_PROTOCOL_VERSION,
type LogLevel,
type PluginTag,
type WireLogRecord,
} from "./protocol/protocol.js";
export * as redactionPassthrough from "./redaction-passthrough/index.js";
export * as server from "./server/index.js";
export {
createLoggerProxyServer,
type LoggerProxyServer,
type LoggerProxyServerOptions,
} from "./server/server.js";
export * as testing from "./testing/index.js";

View File

@@ -0,0 +1,21 @@
export {
type ControlFrame,
controlFrameSchema,
LOG_LEVELS,
LOGGER_PROXY_PROTOCOL_VERSION,
type LogBatchFrame,
type LogGoodbyeFrame,
type LoggerProxyFrame,
type LogLevel,
logBatchFrameSchema,
logGoodbyeFrameSchema,
loggerProxyFrameSchema,
logLevelSchema,
logRecordSchema,
type PluginTag,
parseLoggerProxyFrame,
pluginTagSchema,
type TraceFields,
traceFieldsSchema,
type WireLogRecord,
} from "./protocol.js";

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,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LOG_LEVELS,
LOGGER_PROXY_PROTOCOL_VERSION,
parseLoggerProxyFrame,
} from "./protocol.js";
describe("protocol", () => {
it("pins the protocol version", { timeout: 5000 }, () => {
expect(LOGGER_PROXY_PROTOCOL_VERSION).toBe("1.0.0");
});
it("exposes every level", { timeout: 5000 }, () => {
expect(LOG_LEVELS).toEqual([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
]);
});
it("parses a log-batch frame", { timeout: 5000 }, () => {
const f = parseLoggerProxyFrame({
v: "1.0.0",
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [
{
level: "info",
msg: "hi",
ts: "2026-05-25T00:00:00.000Z",
ctx: { x: 1 },
trace: { trace_id: "t", span_id: "s" },
},
],
});
expect(f.kind).toBe("log-batch");
if (f.kind !== "log-batch") throw new Error("narrow");
expect(f.plugin.name).toBe("p");
expect(f.records[0]?.ctx?.x).toBe(1);
});
it("parses a control frame", { timeout: 5000 }, () => {
const f = parseLoggerProxyFrame({
v: "1.0.0",
kind: "control",
action: "disable",
});
expect(f.kind).toBe("control");
if (f.kind !== "control") throw new Error("narrow");
expect(f.action).toBe("disable");
});
it("parses a goodbye frame", { timeout: 5000 }, () => {
const f = parseLoggerProxyFrame({
v: "1.0.0",
kind: "goodbye",
reason: "client-dispose",
});
expect(f.kind).toBe("goodbye");
});
it("rejects malformed input", { timeout: 5000 }, () => {
expect(() => parseLoggerProxyFrame({ kind: "nope" })).toThrow();
expect(() =>
parseLoggerProxyFrame({
v: "1.0.0",
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [],
})
).toThrow();
expect(() =>
parseLoggerProxyFrame({
v: "1.0.0",
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [{ level: "notalevel", msg: "x", ts: "2026" }],
})
).toThrow();
});
});

View File

@@ -0,0 +1,109 @@
import z from "zod";
/**
* Wire-protocol version embedded in every frame. Receivers reject frames
* whose major version is incompatible with their own.
*/
export const LOGGER_PROXY_PROTOCOL_VERSION = "1.0.0" as const;
/**
* Plugin identity attached to every record sent over the proxy. Pre-tagged
* worker-side per design doc §5.1 so the main-side logger does not need to
* recompute the plugin metadata on every record.
*/
export interface PluginTag {
readonly name: string;
readonly version: string;
}
export const pluginTagSchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
});
/**
* Log level transported on the wire. A superset of `LoggerLike` levels:
* `trace` and `fatal` are accepted for forward-compatibility with richer
* downstream loggers; the main-side server maps them down to the four
* `LoggerLike` levels (`trace → debug`, `fatal → error`).
*/
export const LOG_LEVELS = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export const logLevelSchema = z.enum(LOG_LEVELS);
/**
* Tracing fields propagated from the worker. Optional because not every
* call site runs inside a span. The main-side server pre-pends these to
* the merged context before forwarding to the main logger.
*/
export const traceFieldsSchema = z.object({
trace_id: z.string().min(1).optional(),
span_id: z.string().min(1).optional(),
});
export type TraceFields = z.infer<typeof traceFieldsSchema>;
/**
* A single log record on the wire. `ts` is set worker-side; main-side
* MAY override only if missing. `ctx` carries arbitrary structured
* fields; the schema accepts `unknown` rather than a constrained record
* so the proxy stays transparent to the application.
*/
export const logRecordSchema = z.object({
level: logLevelSchema,
msg: z.string(),
ts: z.string().min(1),
ctx: z.record(z.string(), z.unknown()).optional(),
trace: traceFieldsSchema.optional(),
});
export type WireLogRecord = z.infer<typeof logRecordSchema>;
const baseFrame = z.object({ v: z.string().min(1) });
/** Batch of one-or-more records sent from worker to main. */
export const logBatchFrameSchema = baseFrame.extend({
kind: z.literal("log-batch"),
plugin: pluginTagSchema,
records: z.array(logRecordSchema).min(1),
});
/** Worker → main on graceful close. */
export const logGoodbyeFrameSchema = baseFrame.extend({
kind: z.literal("goodbye"),
reason: z.string(),
});
/** Main → worker control message. */
export const controlFrameSchema = baseFrame.extend({
kind: z.literal("control"),
action: z.enum(["disable", "enable"]),
});
export const loggerProxyFrameSchema = z.union([
logBatchFrameSchema,
logGoodbyeFrameSchema,
controlFrameSchema,
]);
export type LogBatchFrame = z.infer<typeof logBatchFrameSchema>;
export type LogGoodbyeFrame = z.infer<typeof logGoodbyeFrameSchema>;
export type ControlFrame = z.infer<typeof controlFrameSchema>;
export type LoggerProxyFrame = z.infer<typeof loggerProxyFrameSchema>;
/**
* Parse and validate an inbound wire frame. Throws `ZodError` on malformed
* input; callers map to `LOGGER_PROXY.INVALID_FRAME`.
*/
export function parseLoggerProxyFrame(input: unknown): LoggerProxyFrame {
return loggerProxyFrameSchema.parse(input);
}

View File

@@ -0,0 +1,6 @@
export {
applyRedactionPassthrough,
maskKeys,
type PassthroughRedactionOptions,
type Redactor,
} from "./redaction-passthrough.js";

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,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { WireLogRecord } from "../protocol/protocol.js";
import {
applyRedactionPassthrough,
maskKeys,
} from "./redaction-passthrough.js";
const sample = (
ctx?: Record<string, unknown>,
msg = "hello"
): WireLogRecord => ({
level: "info",
msg,
ts: "2026-05-25T00:00:00.000Z",
...(ctx !== undefined ? { ctx } : {}),
});
describe("redaction-passthrough", () => {
it("returns the input by reference when no redactors are configured", {
timeout: 5000,
}, () => {
const rec = sample({ a: 1 });
const out = applyRedactionPassthrough(rec, {});
expect(out).toBe(rec);
});
it("applies ctxRedactor to ctx", { timeout: 5000 }, () => {
const rec = sample({ password: "x", a: 1 });
const out = applyRedactionPassthrough(rec, {
ctxRedactor: maskKeys(["password"]),
});
expect(out).not.toBe(rec);
expect(out.ctx?.password).toBe("[REDACTED]");
expect(out.ctx?.a).toBe(1);
});
it("applies msgRedactor to msg", { timeout: 5000 }, () => {
const rec = sample({}, "card 1234567890123456 leaked");
const out = applyRedactionPassthrough(rec, {
msgRedactor: (m) => m.replace(/\d{16}/, "[CARD]"),
});
expect(out.msg).toBe("card [CARD] leaked");
});
it("ctxRedactor on a record with no ctx is a no-op for ctx", {
timeout: 5000,
}, () => {
const rec = sample();
const out = applyRedactionPassthrough(rec, {
ctxRedactor: maskKeys(["x"]),
msgRedactor: (m) => m.toUpperCase(),
});
expect(out.ctx).toBeUndefined();
expect(out.msg).toBe("HELLO");
});
it("maskKeys uses default replacement string", { timeout: 5000 }, () => {
const r = maskKeys(["k"]);
const out = r({ k: "x", a: 1 });
expect(out.k).toBe("[REDACTED]");
});
it("maskKeys accepts a custom replacement", { timeout: 5000 }, () => {
const r = maskKeys(["k"], "***");
const out = r({ k: "x" });
expect(out.k).toBe("***");
});
it("maskKeys is a no-op on non-objects", { timeout: 5000 }, () => {
const r = maskKeys(["x"]);
expect(r(null)).toBe(null);
expect(r(42)).toBe(42);
expect(r("hello")).toBe("hello");
});
it("maskKeys passes arrays through unchanged", { timeout: 5000 }, () => {
const r = maskKeys(["x"]);
const arr = [{ x: 1 }, { x: 2 }];
expect(r(arr)).toBe(arr);
});
});

View File

@@ -0,0 +1,68 @@
import type { WireLogRecord } from "../protocol/protocol.js";
/**
* Function shape produced by `@suaveplan/logger`'s redaction module. We
* accept a structural type so the package does not pin a specific
* version of `@suaveplan/logger`; any function that takes a record-shaped
* value and returns a redacted clone is acceptable.
*/
export type Redactor = <T>(value: T) => T;
/**
* Wraps a record-redactor so it can be applied to {@link WireLogRecord}
* payloads inbound from a worker. The redactor operates on the `ctx`
* payload (where secrets typically appear) AND on the `msg` if a
* `msgRedactor` is supplied. Order: ctx first, then msg, so callers can
* pass a single function for both.
*/
export interface PassthroughRedactionOptions {
/** Applied to `record.ctx` (the structured context map). */
readonly ctxRedactor?: Redactor;
/** Applied to `record.msg` (the human-readable message). */
readonly msgRedactor?: (msg: string) => string;
}
/**
* Apply the configured redactors to a wire record. Returns a new
* record; the input is never mutated. If no redactors are configured
* the input is returned by reference for zero-cost passthrough.
*/
export function applyRedactionPassthrough(
record: WireLogRecord,
options: PassthroughRedactionOptions
): WireLogRecord {
const { ctxRedactor, msgRedactor } = options;
if (ctxRedactor === undefined && msgRedactor === undefined) return record;
const next: WireLogRecord = { ...record };
if (ctxRedactor !== undefined && next.ctx !== undefined) {
next.ctx = ctxRedactor(next.ctx);
}
if (msgRedactor !== undefined) {
next.msg = msgRedactor(next.msg);
}
return next;
}
/**
* Build a redactor that masks specific top-level keys with a fixed
* replacement string. Equivalent to `@suaveplan/logger`'s redaction
* when only a fixed-key list is configured; supplied here so the
* passthrough layer has a sensible default for callers who don't want
* to wire the full logger redaction graph.
*/
export function maskKeys(
keys: ReadonlyArray<string>,
replacement = "[REDACTED]"
): Redactor {
const keySet = new Set(keys);
return (<T>(value: T): T => {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value;
const obj = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(obj)) {
out[k] = keySet.has(k) ? replacement : obj[k];
}
return out as T;
}) as Redactor;
}

View File

@@ -0,0 +1,7 @@
export {
createLoggerProxyServer,
type LoggerProxyServer,
type LoggerProxyServerOptions,
type OnInvalidFrame,
type OnRecord,
} from "./server.js";

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,351 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import {
LOGGER_PROXY_CODES,
type LoggerProxyError,
} from "../error-codes/error-codes.js";
import { LOGGER_PROXY_PROTOCOL_VERSION } from "../protocol/protocol.js";
import { maskKeys } from "../redaction-passthrough/redaction-passthrough.js";
import { createInProcessPortPair } from "../testing/testing.js";
import { createLoggerProxyServer } from "./server.js";
const wait = (ms = 15): Promise<void> => new Promise((r) => setTimeout(r, ms));
function captureLogger(): {
logger: LoggerLike;
calls: Array<{
level: "debug" | "info" | "warn" | "error";
msg: string;
ctx: Record<string, unknown>;
}>;
} {
const calls: Array<{
level: "debug" | "info" | "warn" | "error";
msg: string;
ctx: Record<string, unknown>;
}> = [];
return {
calls,
logger: {
debug: (msg, ctx) =>
calls.push({ level: "debug", msg, ctx: ctx ?? {} }),
info: (msg, ctx) =>
calls.push({ level: "info", msg, ctx: ctx ?? {} }),
warn: (msg, ctx) =>
calls.push({ level: "warn", msg, ctx: ctx ?? {} }),
error: (msg, ctx) =>
calls.push({ level: "error", msg, ctx: ctx ?? {} }),
},
};
}
describe("createLoggerProxyServer", () => {
it("narrows trace → debug and fatal → error", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger);
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [
{ level: "trace", msg: "t", ts: "2026" },
{ level: "fatal", msg: "f", ts: "2026" },
],
});
await wait();
expect(calls.map((c) => c.level)).toEqual(["debug", "error"]);
expect(server.forwardedCount).toBe(2);
server.close();
pair.close();
});
it("uses logger.child(plugin) when available", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const childCalls: Array<{
msg: string;
bindings: Record<string, unknown>;
}> = [];
let bindings: Record<string, unknown> = {};
const logger: LoggerLike = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
child: (b) => {
bindings = { ...b };
return {
debug: () => {},
info: (msg) => childCalls.push({ msg, bindings }),
warn: () => {},
error: () => {},
};
},
};
const server = createLoggerProxyServer(pair.mainPort, logger);
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "px", version: "9.9.9" },
records: [{ level: "info", msg: "via-child", ts: "2026" }],
});
await wait();
expect(childCalls[0]?.msg).toBe("via-child");
expect(childCalls[0]?.bindings.plugin).toBe("px");
expect(childCalls[0]?.bindings.plugin_version).toBe("9.9.9");
server.close();
pair.close();
});
it("applies redaction at the boundary", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
redaction: { ctxRedactor: maskKeys(["password"]) },
});
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [
{
level: "info",
msg: "x",
ts: "2026",
ctx: { password: "hunter2", a: 1 },
},
],
});
await wait();
expect(calls[0]?.ctx.password).toBe("[REDACTED]");
expect(calls[0]?.ctx.a).toBe(1);
server.close();
pair.close();
});
it("surfaces malformed frames via INVALID_FRAME error and counter", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const errors: LoggerProxyError[] = [];
const invalid: unknown[] = [];
const { logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
onError: (e) => errors.push(e),
onInvalidFrame: (input) => invalid.push(input),
});
pair.workerPort.postMessage({ kind: "bogus" });
await wait();
expect(errors.length).toBe(1);
expect(errors[0]?.code).toBe(LOGGER_PROXY_CODES.INVALID_FRAME);
expect(server.invalidFrameCount).toBe(1);
expect(invalid.length).toBe(1);
server.close();
pair.close();
});
it("rejects batches over the maxBatchSize cap", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const errors: LoggerProxyError[] = [];
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
maxBatchSize: 2,
onError: (e) => errors.push(e),
});
const records = Array.from({ length: 3 }, (_, i) => ({
level: "info" as const,
msg: `m${i}`,
ts: "2026",
}));
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records,
});
await wait();
expect(calls.length).toBe(0);
expect(server.overflowDropCount).toBe(3);
expect(errors[0]?.code).toBe(LOGGER_PROXY_CODES.QUEUE_OVERFLOW);
server.close();
pair.close();
});
it("surfaces a goodbye frame via onError(PORT_CLOSED)", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const errors: LoggerProxyError[] = [];
const { logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
onError: (e) => errors.push(e),
});
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "goodbye",
reason: "client-dispose",
});
await wait();
expect(errors[0]?.code).toBe(LOGGER_PROXY_CODES.PORT_CLOSED);
server.close();
pair.close();
});
it("ignores incoming control frames (host-originated, not for the server)", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const errors: LoggerProxyError[] = [];
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
onError: (e) => errors.push(e),
});
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "control",
action: "disable",
});
await wait();
expect(calls.length).toBe(0);
expect(errors.length).toBe(0);
server.close();
pair.close();
});
it("swallows host-logger throws and surfaces via onError", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const errors: LoggerProxyError[] = [];
const logger: LoggerLike = {
debug: () => {},
info: () => {
throw new Error("sink fail");
},
warn: () => {},
error: () => {},
};
const server = createLoggerProxyServer(pair.mainPort, logger, {
onError: (e) => errors.push(e),
});
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [{ level: "info", msg: "x", ts: "2026" }],
});
await wait();
expect(
errors.some((e) => e.code === LOGGER_PROXY_CODES.PORT_CLOSED)
).toBe(true);
server.close();
pair.close();
});
it("close is idempotent and stops dispatching", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger);
server.close();
server.close();
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [{ level: "info", msg: "after-close", ts: "2026" }],
});
await wait();
expect(calls.length).toBe(0);
pair.close();
});
it("onRecord hook fires after redaction", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const observed: string[] = [];
const { logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger, {
onRecord: (r) => observed.push(r.msg),
});
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [{ level: "warn", msg: "watch", ts: "2026" }],
});
await wait();
expect(observed).toEqual(["watch"]);
server.close();
pair.close();
});
it("caches the child logger across batches with the same plugin tag", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
let childCount = 0;
const logger: LoggerLike = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
child: () => {
childCount++;
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
},
};
const server = createLoggerProxyServer(pair.mainPort, logger);
const plugin = { name: "p", version: "0.1.0" };
for (let i = 0; i < 5; i++) {
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin,
records: [{ level: "info", msg: `m${i}`, ts: "2026" }],
});
}
await wait();
expect(childCount).toBe(1);
server.close();
pair.close();
});
it("propagates trace_id and span_id into the dispatched ctx", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const { calls, logger } = captureLogger();
const server = createLoggerProxyServer(pair.mainPort, logger);
pair.workerPort.postMessage({
v: LOGGER_PROXY_PROTOCOL_VERSION,
kind: "log-batch",
plugin: { name: "p", version: "0.1.0" },
records: [
{
level: "info",
msg: "x",
ts: "2026",
trace: { trace_id: "tid", span_id: "sid" },
},
],
});
await wait();
expect(calls[0]?.ctx.trace_id).toBe("tid");
expect(calls[0]?.ctx.span_id).toBe("sid");
server.close();
pair.close();
});
});

View File

@@ -0,0 +1,208 @@
import type { LoggerLike } from "@suaveplan/types";
import {
LOGGER_PROXY_CODES,
LoggerProxyError,
} from "../error-codes/error-codes.js";
import {
type LogLevel,
type PluginTag,
parseLoggerProxyFrame,
type WireLogRecord,
} from "../protocol/protocol.js";
import {
applyRedactionPassthrough,
type PassthroughRedactionOptions,
} from "../redaction-passthrough/redaction-passthrough.js";
import type { PortLike } from "../testing/testing.js";
/**
* Hook fired for every record received from the worker after redaction
* and level-normalisation. Lets consumers attach OTel events or fan
* out to additional sinks beyond the main logger.
*/
export type OnRecord = (record: WireLogRecord, plugin: PluginTag) => void;
/**
* Hook fired for every malformed inbound frame. The server already
* surfaces the failure via `onError`; this is an additional observability
* seam that gets the raw failing input for diagnostics.
*/
export type OnInvalidFrame = (input: unknown, error: unknown) => void;
export interface LoggerProxyServerOptions {
readonly redaction?: PassthroughRedactionOptions;
readonly onRecord?: OnRecord;
readonly onError?: (error: LoggerProxyError) => void;
readonly onInvalidFrame?: OnInvalidFrame;
/**
* Hard cap on the records-per-batch the server is willing to forward.
* Defaults to 10_000. A batch exceeding the cap is rejected with
* `LOGGER_PROXY.QUEUE_OVERFLOW`; the records are NOT forwarded.
*/
readonly maxBatchSize?: number;
}
export interface LoggerProxyServer {
/** Detach from the port. Idempotent. */
close(): void;
/** Records forwarded since construction. */
readonly forwardedCount: number;
/** Frames rejected for malformed input. */
readonly invalidFrameCount: number;
/** Records rejected for exceeding `maxBatchSize`. */
readonly overflowDropCount: number;
}
const LEVEL_MAP: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
trace: "debug",
debug: "debug",
info: "info",
warn: "warn",
error: "error",
fatal: "error",
};
/**
* Mount a logger-proxy server on a `MessagePort`. The server consumes
* inbound `log-batch` frames, applies redaction, narrows `trace` /
* `fatal` to the four `LoggerLike` levels, and forwards each record
* to the supplied main-side logger. Plugin pre-tagging is honoured by
* routing each record through `logger.child(plugin)` when the host
* logger supports child loggers; otherwise the plugin fields are merged
* into the call-site context.
*
* The server is best-effort: malformed frames increment the
* `invalidFrameCount` counter and surface via the `onError` hook but
* do not throw. Sink failures from the underlying logger are caught and
* surfaced via `onError` so a single bad record cannot break the port
* listener.
*/
export function createLoggerProxyServer(
port: PortLike,
logger: LoggerLike,
options: LoggerProxyServerOptions = {}
): LoggerProxyServer {
let closed = false;
let forwarded = 0;
let invalidFrames = 0;
let overflowDrops = 0;
const maxBatch = options.maxBatchSize ?? 10_000;
const childCache = new Map<string, LoggerLike>();
const resolveChild = (plugin: PluginTag): LoggerLike => {
const key = `${plugin.name}@${plugin.version}`;
const cached = childCache.get(key);
if (cached !== undefined) return cached;
const built =
logger.child !== undefined
? logger.child({
plugin: plugin.name,
plugin_version: plugin.version,
})
: logger;
childCache.set(key, built);
return built;
};
const dispatch = (record: WireLogRecord, plugin: PluginTag): void => {
const redacted =
options.redaction !== undefined
? applyRedactionPassthrough(record, options.redaction)
: record;
options.onRecord?.(redacted, plugin);
const level = LEVEL_MAP[redacted.level];
const target = resolveChild(plugin);
const ctx: Record<string, unknown> = {
...(redacted.ctx ?? {}),
plugin: plugin.name,
plugin_version: plugin.version,
ts: redacted.ts,
};
if (redacted.trace?.trace_id !== undefined) {
ctx["trace_id"] = redacted.trace.trace_id;
}
if (redacted.trace?.span_id !== undefined) {
ctx["span_id"] = redacted.trace.span_id;
}
try {
target[level](redacted.msg, ctx);
forwarded++;
} catch (cause) {
options.onError?.(
new LoggerProxyError(
LOGGER_PROXY_CODES.PORT_CLOSED,
"host logger threw while forwarding record",
{ cause }
)
);
}
};
const listener = (event: { data: unknown }): void => {
let frame: ReturnType<typeof parseLoggerProxyFrame>;
try {
frame = parseLoggerProxyFrame(event.data);
} catch (cause) {
invalidFrames++;
options.onInvalidFrame?.(event.data, cause);
options.onError?.(
new LoggerProxyError(
LOGGER_PROXY_CODES.INVALID_FRAME,
"received malformed logger-proxy frame",
{ cause }
)
);
return;
}
if (frame.kind === "log-batch") {
if (frame.records.length > maxBatch) {
overflowDrops += frame.records.length;
options.onError?.(
new LoggerProxyError(
LOGGER_PROXY_CODES.QUEUE_OVERFLOW,
`inbound batch size ${frame.records.length} exceeds cap ${maxBatch}`
)
);
return;
}
for (const record of frame.records) {
dispatch(record, frame.plugin);
}
return;
}
if (frame.kind === "goodbye") {
// Worker requested close; the host MAY tear down the port now
// but we leave that decision to the caller (which owns the
// lifecycle). Surface via onError so the host can log it.
options.onError?.(
new LoggerProxyError(
LOGGER_PROXY_CODES.PORT_CLOSED,
`worker sent goodbye: ${frame.reason}`
)
);
return;
}
// control frames are sent by the host TO the worker; if a host-side
// server receives one (echo or mis-routing) we ignore it.
};
port.addEventListener("message", listener);
port.start?.();
return {
close(): void {
if (closed) return;
closed = true;
port.removeEventListener("message", listener);
},
get forwardedCount(): number {
return forwarded;
},
get invalidFrameCount(): number {
return invalidFrames;
},
get overflowDropCount(): number {
return overflowDrops;
},
};
}

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,95 @@
import type { LoggerLike } from "@suaveplan/types";
import {
createLoggerProxyClient,
type LoggerProxyClient,
} from "../client/client.js";
import { createControlChannelSender } from "../control-channel/control-channel.js";
import type { LogLevel, PluginTag } from "../protocol/protocol.js";
import {
createLoggerProxyServer,
type LoggerProxyServer,
} from "../server/server.js";
import { createInProcessPortPair } from "./testing.js";
/**
* Captured forwarded record. Mirrors the call shape of `LoggerLike` so
* tests can assert on the level method name, the message, and the
* structured context the proxy actually forwarded to the main logger.
*/
export interface ForwardedRecord {
readonly level: "debug" | "info" | "warn" | "error";
readonly msg: string;
readonly ctx: Readonly<Record<string, unknown>>;
}
export interface ConformanceHarness {
readonly client: LoggerProxyClient;
readonly server: LoggerProxyServer;
readonly forwarded: ForwardedRecord[];
readonly controlSender: ReturnType<typeof createControlChannelSender>;
dispose(): void;
}
/**
* Build a paired in-process harness for conformance testing. Wires the
* worker-side client and main-side server on a `MessageChannel`,
* captures every record dispatched to the main logger, and exposes a
* control sender so tests can drive `disable` / `enable` transitions.
*/
export function runLoggerProxyConformance(
plugin: PluginTag,
options: {
readonly batching?: Parameters<typeof createLoggerProxyClient>[2];
} = {}
): ConformanceHarness {
const forwarded: ForwardedRecord[] = [];
// The main-side server always supplies a non-undefined `ctx` (it
// composes one with plugin fields before dispatch), so each level
// method receives a record-shaped second argument.
const push = (
level: ForwardedRecord["level"],
msg: string,
ctx: Readonly<Record<string, unknown>> | undefined
): void => {
forwarded.push({
level,
msg,
ctx: ctx as Readonly<Record<string, unknown>>,
});
};
const mainLogger: LoggerLike = {
debug: (msg, ctx) => push("debug", msg, ctx),
info: (msg, ctx) => push("info", msg, ctx),
warn: (msg, ctx) => push("warn", msg, ctx),
error: (msg, ctx) => push("error", msg, ctx),
};
const pair = createInProcessPortPair();
const server = createLoggerProxyServer(pair.mainPort, mainLogger);
const client = createLoggerProxyClient(
pair.workerPort,
plugin,
options.batching
);
const controlSender = createControlChannelSender(pair.mainPort);
return {
client,
server,
forwarded,
controlSender,
dispose: () => {
client.dispose();
server.close();
pair.close();
},
};
}
/** Convenience: every wire-format level a worker may emit. */
export const ALL_LEVELS: ReadonlyArray<LogLevel> = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
];

View File

@@ -0,0 +1,9 @@
export { runLoggerProxyConformance } from "./conformance.js";
export {
buildRecord,
type CapturedRecord,
CapturedSink,
createInProcessPortPair,
type InProcessPortPair,
type PortLike,
} from "./testing.js";

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`.

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ALL_LEVELS, runLoggerProxyConformance } from "./conformance.js";
import {
buildRecord,
CapturedSink,
createInProcessPortPair,
} from "./testing.js";
const wait = (ms = 15): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe("testing helpers", () => {
it("createInProcessPortPair round-trips a message", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const received: unknown[] = [];
pair.mainPort.addEventListener("message", (e) => received.push(e.data));
pair.workerPort.start?.();
pair.workerPort.postMessage({ hello: "world" });
await wait();
expect(received).toEqual([{ hello: "world" }]);
pair.close();
});
it("removeEventListener detaches", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const seen: unknown[] = [];
const listener = (e: { data: unknown }): void => {
seen.push(e.data);
};
pair.mainPort.addEventListener("message", listener);
pair.workerPort.postMessage("a");
await wait();
pair.mainPort.removeEventListener("message", listener);
pair.workerPort.postMessage("b");
await wait();
expect(seen).toEqual(["a"]);
// removing an unknown listener is a no-op
pair.mainPort.removeEventListener("message", () => {});
pair.close();
});
it("buildRecord composes records with defaults", { timeout: 5000 }, () => {
const r1 = buildRecord("info", "x");
expect(r1.level).toBe("info");
expect(typeof r1.ts).toBe("string");
expect(r1.ctx).toBeUndefined();
const r2 = buildRecord(
"warn",
"y",
{ a: 1 },
{ trace_id: "t" },
"2026"
);
expect(r2.ctx?.a).toBe(1);
expect(r2.trace?.trace_id).toBe("t");
expect(r2.ts).toBe("2026");
});
it("CapturedSink stores and resets records", { timeout: 5000 }, () => {
const sink = new CapturedSink();
sink.push({
level: "info",
msg: "x",
ts: "t",
plugin: { name: "p", version: "v" },
ctx: {},
});
expect(sink.records.length).toBe(1);
sink.reset();
expect(sink.records.length).toBe(0);
});
it("ALL_LEVELS covers every wire-format level", { timeout: 5000 }, () => {
expect(ALL_LEVELS).toEqual([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
]);
});
it("runLoggerProxyConformance: full client → server round-trip", {
timeout: 5000,
}, async () => {
const h = runLoggerProxyConformance({
name: "demo",
version: "0.1.0",
});
h.client.info("hi");
h.client.flush();
await wait();
expect(h.forwarded[0]?.msg).toBe("hi");
h.dispose();
});
it("port.start and port.close are available on the wrapped port", {
timeout: 5000,
}, () => {
const pair = createInProcessPortPair();
expect(typeof pair.mainPort.start).toBe("function");
expect(typeof pair.mainPort.close).toBe("function");
pair.mainPort.start?.();
pair.mainPort.close?.();
pair.close();
});
});

View File

@@ -0,0 +1,128 @@
import type { LogLevel, WireLogRecord } from "../protocol/protocol.js";
/**
* Minimal `MessagePort`-shaped surface used across the package. Aligns with
* the Web `MessagePort` AND Node/Bun's `worker_threads` `MessagePort`. The
* structural type lets us stay universal — no platform globals at module
* load.
*/
export interface PortLike {
postMessage(value: unknown): void;
addEventListener(
type: "message",
listener: (event: { data: unknown }) => void
): void;
removeEventListener(
type: "message",
listener: (event: { data: unknown }) => void
): void;
start?: () => void;
close?: () => void;
}
/** A paired-port factory used by conformance suites. */
export interface InProcessPortPair {
readonly workerPort: PortLike;
readonly mainPort: PortLike;
close(): void;
}
/**
* Build an in-process linked pair of {@link PortLike} ports via the
* platform `MessageChannel`. Available natively in Node 18+, Bun, and
* browsers. Bidirectional and zero-copy for `Transferable`s.
*/
export function createInProcessPortPair(): InProcessPortPair {
const channel = new MessageChannel();
const a = wrapPort(channel.port1);
const b = wrapPort(channel.port2);
return {
workerPort: a,
mainPort: b,
close: () => {
channel.port1.close();
channel.port2.close();
},
};
}
function wrapPort(port: MessagePort): PortLike {
port.start?.();
const wrappers = new WeakMap<
(event: { data: unknown }) => void,
EventListener
>();
return {
postMessage: (value: unknown) => {
port.postMessage(value);
},
addEventListener: (_type, listener) => {
const wrapper: EventListener = (event) =>
listener({ data: (event as MessageEvent).data });
wrappers.set(listener, wrapper);
port.addEventListener("message", wrapper);
},
removeEventListener: (_type, listener) => {
const wrapper = wrappers.get(listener);
if (wrapper !== undefined) {
port.removeEventListener("message", wrapper);
wrappers.delete(listener);
}
},
start: () => port.start?.(),
close: () => port.close(),
};
}
/**
* A captured-output sink interface for tests. Mirrors the subset of
* `@suaveplan/logger`'s `Sink` we exercise here without forcing the
* test suite to construct a full logger.
*/
export interface CapturedRecord {
readonly level: LogLevel;
readonly msg: string;
readonly ts: string;
readonly plugin: { readonly name: string; readonly version: string };
readonly ctx: Readonly<Record<string, unknown>>;
readonly trace_id?: string;
readonly span_id?: string;
}
/**
* In-memory recorder for `WireLogRecord` deliveries. Conformance suites
* use this as the main-side sink so they can assert on every record that
* reached the boundary.
*/
export class CapturedSink {
public readonly records: CapturedRecord[] = [];
public push(record: CapturedRecord): void {
this.records.push(record);
}
public reset(): void {
this.records.length = 0;
}
}
/**
* Helper: assemble a `WireLogRecord` with sensible defaults. Used by both
* the worker-side client (via the level methods) and tests that need to
* inject records directly into the server.
*/
export function buildRecord(
level: LogLevel,
msg: string,
ctx?: Readonly<Record<string, unknown>>,
trace?: { trace_id?: string; span_id?: string },
ts: string = new Date().toISOString()
): WireLogRecord {
return {
level,
msg,
ts,
...(ctx !== undefined ? { ctx } : {}),
...(trace !== undefined ? { trace } : {}),
};
}

View File

@@ -0,0 +1,37 @@
/**
* §16 conformance proof — the worker-proxy package consumes the
* `@suaveplan/api-worker-rpc` contract directly (client + server +
* MessagePort transport). This test exercises the canonical
* `runWorkerRpcConformance` harness with a minimal handler set to
* prove the consumer is wire-compatible end-to-end (request frame,
* response frame, abort propagation) using the same port transport
* the package builds on top of in `src/main-side` / `src/worker-side`.
*/
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
const handles: Array<{ dispose(): void }> = [];
afterEach(() => {
while (handles.length > 0) handles.pop()?.dispose();
});
describe("api-worker-rpc contract conformance (consumer-side)", () => {
it("round-trips a request through the canonical harness", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
handles.push(h);
const result = await h.client.call<number>("echo", [42]);
expect(result).toBe(42);
});
it("surfaces UNKNOWN_METHOD from the server", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({}));
handles.push(h);
await expect(h.client.call("missing", [])).rejects.toThrow();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-logger-worker-proxy",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});