Initial Commit
This commit is contained in:
98
packages/api-stack/api/api-logger-worker-proxy/README.md
Normal file
98
packages/api-stack/api/api-logger-worker-proxy/README.md
Normal 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
|
||||
|
||||
112
packages/api-stack/api/api-logger-worker-proxy/package.json
Normal file
112
packages/api-stack/api/api-logger-worker-proxy/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type BatcherDropCallback,
|
||||
type BatcherOptions,
|
||||
DEFAULT_BATCHER_OPTIONS,
|
||||
LogBatcher,
|
||||
type TimeoutScheduler,
|
||||
} from "./batching.js";
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ClientClock,
|
||||
createLoggerProxyClient,
|
||||
type LoggerProxyClient,
|
||||
type LoggerProxyClientOptions,
|
||||
type TraceProvider,
|
||||
} from "./client.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type ControlChannelSender,
|
||||
type ControlReceiver,
|
||||
type ControlReceiverOptions,
|
||||
type ControlState,
|
||||
createControlChannelSender,
|
||||
mountControlChannelReceiver,
|
||||
} from "./control-channel.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
LOGGER_PROXY_CODES,
|
||||
type LoggerProxyCode,
|
||||
LoggerProxyError,
|
||||
} from "./error-codes.js";
|
||||
33
packages/api-stack/api/api-logger-worker-proxy/src/index.ts
Normal file
33
packages/api-stack/api/api-logger-worker-proxy/src/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
applyRedactionPassthrough,
|
||||
maskKeys,
|
||||
type PassthroughRedactionOptions,
|
||||
type Redactor,
|
||||
} from "./redaction-passthrough.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createLoggerProxyServer,
|
||||
type LoggerProxyServer,
|
||||
type LoggerProxyServerOptions,
|
||||
type OnInvalidFrame,
|
||||
type OnRecord,
|
||||
} from "./server.js";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
export { runLoggerProxyConformance } from "./conformance.js";
|
||||
export {
|
||||
buildRecord,
|
||||
type CapturedRecord,
|
||||
CapturedSink,
|
||||
createInProcessPortPair,
|
||||
type InProcessPortPair,
|
||||
type PortLike,
|
||||
} from "./testing.js";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user