first commit

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

View File

@@ -0,0 +1,70 @@
# @suaveplan/api-jobs-worker-proxy - Bidirectional MessagePort proxy that lets a worker-isolated API plugin register cron + queued jobs with the host's main-process job scheduler. Worker calls registerCron/registerQueued; main side stores handler refs and dispatches runJob RPC back to the worker when due. Universal. Stage-3 of the worker-isolation chain.
Bidirectional `MessagePort` proxy that lets a worker-isolated API plugin
participate in the host's main-process job scheduler. The worker calls
`registerCron` / `registerQueued` against a local handle implementing
`JobRegistrationHandle` from `@suaveplan/api-types`; the proxy marshals
the registration to the main side, which wires the closure into the
real `JobRegistrationHandle` (typically backed by
`@suaveplan/job-scheduler`). When the scheduler ticks a job, a
`runJob` RPC flows back over the same port to invoke the worker's
handler.
This is worker-isolation chain. The protocol layer
(`@suaveplan/api-worker-rpc`) supplies correlation ids, abort
forwarding, Zod-validated frames, and error reconstruction — this
package adds only the jobs-specific verbs.
<!-- begin:auto-docs -->
## Documentation tree
- [client](./src/client/client.md) — `createJobsProxyClient(port, opts)` is the worker-tier entry point for the `@suaveplan/api-jobs-worker-proxy` package.…
- [client](./src/client/client.md) — `createJobsProxyClient(port, opts)` is the worker-tier entry point for the `@suaveplan/api-jobs-worker-proxy` package.…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-jobs-worker-proxy`. Every public failure path raise…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-jobs-worker-proxy`. Every public failure path raise…
- [handler-bridge](./src/handler-bridge/handler-bridge.md) — This module owns the shared wire schemas, the worker-side `HandlerRegistry`, and the `runWithTimeout` helper used by bo…
- [handler-bridge](./src/handler-bridge/handler-bridge.md) — This module owns the shared wire schemas, the worker-side `HandlerRegistry`, and the `runWithTimeout` helper used by bo…
- [server](./src/server/server.md) — `createJobsProxyServer(port, mainHandle, opts)` runs in the host main process. It receives `registerJob` RPC frames fro…
- [server](./src/server/server.md) — `createJobsProxyServer(port, mainHandle, opts)` runs in the host main process. It receives `registerJob` RPC frames fro…
- [testing](./src/testing/testing.md) — The testing module exposes a fully-wired in-process harness so consumers can exercise the worker-proxy surface without…
- [testing](./src/testing/testing.md) — The testing module exposes a fully-wired in-process harness so consumers can exercise the worker-proxy surface without…
<!-- end:auto-docs -->
## Module map
| Subpath | File | Doc |
|---|---|---|
| `./worker-side` | [`src/client/client.ts`](./src/client/client.ts) | [client.md](./src/client/client.md) |
| `./main-side` | [`src/server/server.ts`](./src/server/server.ts) | [server.md](./src/server/server.md) |
| `./handler-bridge` | [`src/handler-bridge/handler-bridge.ts`](./src/handler-bridge/handler-bridge.ts) | [handler-bridge.md](./src/handler-bridge/handler-bridge.md) |
| `./error-codes` | [`src/error-codes/error-codes.ts`](./src/error-codes/error-codes.ts) | [error-codes.md](./src/error-codes/error-codes.md) |
| `./testing` | [`src/testing/testing.ts`](./src/testing/testing.ts) | [testing.md](./src/testing/testing.md) |
## Scope notes
- **Out of scope: tick delivery for non-jobs RPC.** Job ticks arrive
via the `runJob` request envelope handled here; arbitrary request
delivery for routes is the responsibility of the
`api-router-worker-bridge`-style envelope path.
- **Out of scope: cron expression parsing.** That is owned by
`@suaveplan/cron` and runs on the main side.
- **Out of scope: per-tenant fan-out.** Owned by
`@suaveplan/job-scheduler` plus the `tenant.provider` capability.
## Local conformance harness
The proposal originally referenced `runJobsConformance` from
`@suaveplan/job-scheduler/testing`. That harness does not yet exist;
this package therefore provides its own
`JOBS_CONFORMANCE_CASES` + `runJobsConformance` under `./testing`. The
surface matches what the upstream harness is intended to be — a
factory-driven scenario driver returning per-case pass/fail — so the
in-repo substitute can be swapped out without changing consumers.
## Verification
```bash
bunx turbo lint typecheck test --filter=@suaveplan/api-jobs-worker-proxy
bunx turbo test:coverage --filter=@suaveplan/api-jobs-worker-proxy
```

View File

@@ -0,0 +1,86 @@
# Worker-Side Jobs Proxy Client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createJobsProxyClient(port, opts)` is the worker-tier entry point for the
`@suaveplan/api-jobs-worker-proxy` package. It returns an object that
implements the `JobRegistrationHandle` capability slot from
`@suaveplan/api-types`, letting a worker-isolated API plugin register
cron and queued jobs even though the actual scheduler runs in the host
main process. Every `registerCron` and `registerQueued` call stores the
handler in a local `HandlerRegistry` and asynchronously marshals a
`registerJob` RPC to the main side via the supplied `MessagePort`. When
the main scheduler ticks a job, the same port carries a `runJob`
dispatch back to the worker; the local serve-side listener invokes the
stored handler under an optional timeout.
## Features
- Implements the exact `JobRegistrationHandle` surface (`registerCron`,
`registerQueued`) — no proxy-specific extensions on the public type.
- Bidirectional over a single port: outbound registration RPCs +
inbound `runJob` dispatch via paired raw RPC client + serve handler.
- Fire-and-forget registration semantics with an `onHandlerError`
observer for asynchronous rejections (job-not-declared, port closed).
- Per-handler timeout via `handlerTimeoutMs`; long-running closures
reject with `JOBS_PROXY.JOB_TIMEOUT`.
- Idempotent `close(reason?)` tears down both the RPC client and the
serve listener.
## Usage: Basic
```ts
import { MessageChannel } from "node:worker_threads";
import { createJobsProxyClient } from "@suaveplan/api-jobs-worker-proxy/worker-side";
const channel = new MessageChannel();
const client = createJobsProxyClient(channel.port1);
client.registerCron("rotate-keys", async () => {
await rotate();
});
```
## Usage: Advanced
```ts
import { createJobsProxyClient } from "@suaveplan/api-jobs-worker-proxy/worker-side";
const client = createJobsProxyClient(workerPort, {
handlerTimeoutMs: 30_000,
onHandlerError: (err, ctx) => {
logger.warn({ code: err.code, name: ctx.name, kind: ctx.kind });
},
});
client.registerQueued("send-email", async (payload) => {
await mailer.send(payload as MailPayload);
});
```
## API Reference
- `createJobsProxyClient(port, opts?) -> JobsProxyClient` — factory.
- `JobsProxyClient extends JobRegistrationHandle` — adds `close()` and a
`sizes` accessor reporting registration counts.
- `JobsProxyClientOptions`:
- `handlerTimeoutMs?: number` — per-dispatch timeout, `0` disables.
- `onHandlerError?: (err, ctx) => void` — observer hook.
## Implementation Notes
The client multiplexes a `createRawWorkerRpcClient` (for outbound
`registerJob` calls) and a `serveWorkerRpc` handlers object (for inbound
`runJob`) over the same `PortLike`. Protocol frames partition cleanly:
`response` frames target the RPC client's pending registry, and
`request` frames target the serve dispatcher; each listener silently
drops the other's traffic. The local `HandlerRegistry` is the single
source of truth for which handlers can be dispatched — the main side
relies on the worker holding the closure and we never serialize the
function across the wire. The fire-and-forget `registerJob` call uses
`.catch` to funnel rejections (including `JOB_NOT_DECLARED` from main)
through `onHandlerError`, preserving the original `JobRegistrationHandle`
synchronous-`void` return contract.

View File

@@ -0,0 +1,68 @@
# Error Codes — `JOBS_PROXY.*`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code registry for
`@suaveplan/api-jobs-worker-proxy`. Every public failure path raises a
`JobsProxyError` carrying one of the namespaced `JOBS_PROXY.*` codes,
so consumers can branch on the literal string without depending on
class identity (which is fragile across worker-boundary
reconstruction). The registry is defined once via `defineCodes` to keep
typing tight and prevent accidental drift between docs and runtime.
## Features
- Five stable codes covering the worker ↔ main bridge failure surface.
- `JobsProxyError extends SuaveplanError<JobsProxyCode>` for typed
discrimination via `instanceof` AND `.code`.
- `as const` registry so TypeScript narrows literal codes at use sites.
## Usage: Basic
```ts
import { JobsProxyError, JOBS_PROXY_CODES } from "@suaveplan/api-jobs-worker-proxy/error-codes";
throw new JobsProxyError(
JOBS_PROXY_CODES.JOB_NOT_DECLARED,
"job rotate-keys is not in the manifest",
);
```
## Usage: Advanced
```ts
try {
await client.tickCron("rotate-keys");
} catch (err) {
if (err instanceof JobsProxyError && err.code === "JOBS_PROXY.JOB_TIMEOUT") {
metrics.increment("jobs.timeouts", { name: "rotate-keys" });
}
throw err;
}
```
## API Reference
- `JOBS_PROXY_CODES` — readonly registry of five string codes:
- `JOB_NOT_DECLARED` — name absent from manifest whitelist.
- `HANDLER_THREW` — worker-side handler threw or rejected.
- `PROXY_DISPOSED` — operation attempted after `close()`.
- `JOB_TIMEOUT` — dispatch exceeded `handlerTimeoutMs`.
- `DUPLICATE_REGISTRATION` — same name registered twice.
- `JobsProxyCode` — union of every literal value in the registry.
- `JobsProxyError(code, message, options?)` — typed error class.
## Implementation Notes
`defineCodes("JOBS_PROXY", [...])` (from `@suaveplan/error`) generates an
object whose values are the fully-qualified codes (e.g.
`"JOBS_PROXY.HANDLER_THREW"`). The `JobsProxyCode` type derives from
this object so adding a new code requires only editing the tuple — type
checking surfaces every site that switches on the code. The class sets
`name = "JobsProxyError"` so serialised error envelopes carrying this
class name can be discriminated downstream even when reconstruction
flattens to a generic `SuaveplanError` subclass.

View File

@@ -0,0 +1,68 @@
# Handler Bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the shared wire schemas, the worker-side
`HandlerRegistry`, and the `runWithTimeout` helper used by both ends of
the proxy. The bridge is the only place that decides how a registered
cron/queued handler is exercised on the worker side; isolating it keeps
the client and server modules small and lets the same logic be unit
tested without spinning up a `MessageChannel`. Schemas use `zod` so any
bad payload that crosses the boundary fails fast at the parse step
rather than producing an obscure runtime error downstream.
## Features
- `registerJobRequestSchema` / `runJobRequestSchema` — Zod-validated
wire formats for registration and dispatch frames.
- `HandlerRegistry` — type-safe storage of cron + queued closures with
idempotent set / run / clear semantics and a `sizes` accessor.
- `runWithTimeout` — runs a closure with an optional millisecond budget
and wraps every failure into a typed `JobsProxyError` (`HANDLER_THREW`
or `JOB_TIMEOUT`).
## Usage: Basic
```ts
import { HandlerRegistry } from "@suaveplan/api-jobs-worker-proxy/handler-bridge";
const reg = new HandlerRegistry();
reg.setCron("daily-rotate", async () => rotate());
await reg.runCron("daily-rotate");
```
## Usage: Advanced
```ts
import { runWithTimeout } from "@suaveplan/api-jobs-worker-proxy/handler-bridge";
await runWithTimeout(
() => longRunningExport(),
"export-csv",
30_000,
); // throws JobsProxyError("JOBS_PROXY.JOB_TIMEOUT") after 30s
```
## API Reference
- `registerJobRequestSchema: ZodType<RegisterJobRequest>` — wire schema.
- `runJobRequestSchema: ZodType<RunJobRequest>` — wire schema.
- `HandlerRegistry` — `setCron`, `setQueued`, `hasCron`, `hasQueued`,
`runCron`, `runQueued`, `clear`, `sizes`.
- `runWithTimeout(fn, name, timeoutMs)` — runs `fn`, wraps failures.
## Implementation Notes
`runWithTimeout` uses `Promise.race` between the handler invocation and
a timeout-rejecting promise. The timeout branch always installs a timer
(non-nullable) when `timeoutMs > 0`; the finally-clear is unconditional
once that branch is entered. A `0` budget skips the race entirely and
falls back to a direct `await` so successful handlers do not pay the
extra microtask cost. `HandlerRegistry.setCron`/`setQueued` return a
boolean indicating whether the name already existed, allowing callers to
detect (and warn on) duplicate registrations without forbidding the
replacement.

View File

@@ -0,0 +1,86 @@
# Main-Side Jobs Proxy Server
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createJobsProxyServer(port, mainHandle, opts)` runs in the host main
process. It receives `registerJob` RPC frames from a worker-side
`createJobsProxyClient` over the supplied `MessagePort`, validates the
incoming job name against the plugin's manifest-declared jobs, and
forwards the registration to the host's real
`JobRegistrationHandle` (which in practice wraps
`@suaveplan/job-scheduler`). Each successful registration is bound to a
closure that, when ticked by the scheduler, dispatches a `runJob` RPC
back to the worker. This is The piece that lets a plugin's
worker-isolated code participate in the main-process cron/queue
lifecycle without sharing memory.
## Features
- Manifest-bound: `declaredJobs` is the authoritative whitelist. Names
not in the list reject with `JOBS_PROXY.JOB_NOT_DECLARED`.
- Kind enforcement: registering a name as `cron` when the manifest
declares it `queued` (or vice versa) is also rejected as
`JOB_NOT_DECLARED`.
- Optional per-dispatch RPC timeout (`runRpcTimeoutMs`) so a hung
worker handler does not stall scheduler ticks indefinitely.
- `onDispatchError` observer for telemetry on failed worker
invocations.
- Idempotent `close()` tears down the inbound listener AND the outbound
RPC client.
- `registeredCount` accessor for tests + diagnostics.
## Usage: Basic
```ts
import { MessageChannel } from "node:worker_threads";
import { createJobsProxyServer } from "@suaveplan/api-jobs-worker-proxy/main-side";
const server = createJobsProxyServer(channel.port2, hostScheduler, {
declaredJobs: [{ name: "rotate-keys", kind: "cron" }],
});
```
## Usage: Advanced
```ts
import { createJobsProxyServer } from "@suaveplan/api-jobs-worker-proxy/main-side";
const server = createJobsProxyServer(channel.port2, hostScheduler, {
declaredJobs: manifest.jobs,
runRpcTimeoutMs: 60_000,
onDispatchError: (err, ctx) => metrics.increment("jobs.dispatch_failed", { name: ctx.name }),
});
// Later, on teardown:
server.close("plugin-unload");
```
## API Reference
- `createJobsProxyServer(port, mainHandle, opts) -> JobsProxyServer`.
- `JobsProxyServer`:
- `close(reason?: string): void` — idempotent disposer.
- `registeredCount: number` — count of accepted registrations.
- `JobsProxyServerOptions`:
- `declaredJobs: ReadonlyArray<{name, kind}>` — manifest whitelist.
- `runRpcTimeoutMs?: number` — optional per-tick RPC timeout.
- `onDispatchError?: (err, ctx) => void` — telemetry hook.
## Implementation Notes
The server mounts a `serveWorkerRpc` handlers object exposing a single
`registerJob` method that parses incoming payloads with
`registerJobRequestSchema`. The manifest whitelist is normalised into a
`Map<name, kind>` at construction time for O(1) lookups. When a
registration is accepted, the closure stored with the host scheduler
captures the supplied `name`/`kind` and, on tick, invokes
`rpc.call("runJob", [{name, kind, payload}])` via a paired
`createRawWorkerRpcClient`. The dispatcher checks `closed` first so a
late tick after `close()` raises `JOBS_PROXY.PROXY_DISPOSED` instead of
posting on a dead port. The server intentionally has no opinion about
backpressure: the underlying RPC layer already supplies a bounded
outbound queue.

View File

@@ -0,0 +1,84 @@
# Testing Harness and Conformance Suite
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The testing module exposes a fully-wired in-process harness so consumers
can exercise the worker-proxy surface without launching real workers,
plus a conformance suite that codifies the invariants every
`JobRegistrationHandle` proxy must satisfy. Because
`@suaveplan/job-scheduler` does not yet ship a `runJobsConformance`
harness, the canonical Stage-4 gate referred to in the proposal is
provided locally here; downstream packages can replace it transparently
once the upstream harness lands without touching the public surface.
## Features
- `StubJobScheduler` — implements `JobRegistrationHandle` with imperative
`tickCron(name)` / `tickQueued(name, payload)` methods for tests.
- `createJobsProxyHarness(opts)` — bundles a `MessageChannel`, the stub
scheduler, a `JobsProxyServer`, and a `JobsProxyClient` into a single
closable handle with a `flush()` helper.
- `JOBS_CONFORMANCE_CASES` + `runJobsConformance(factory)` — three
scenarios covering cron round-trip, queued round-trip with payload,
and rejection of non-declared names.
- `emptyHandler` — exported no-op cron closure used by negative-path
scenarios.
## Usage: Basic
```ts
import { createJobsProxyHarness } from "@suaveplan/api-jobs-worker-proxy/testing";
const h = createJobsProxyHarness({
declaredJobs: [{ name: "rotate", kind: "cron" }],
});
h.client.registerCron("rotate", async () => doWork());
await h.flush();
await h.scheduler.tickCron("rotate");
h.close();
```
## Usage: Advanced
```ts
import { createJobsProxyHarness, runJobsConformance } from "@suaveplan/api-jobs-worker-proxy/testing";
await runJobsConformance(() =>
createJobsProxyHarness({
declaredJobs: [
{ name: "daily-rotate", kind: "cron" },
{ name: "send-email", kind: "queued" },
{ name: "ok", kind: "cron" },
],
handlerTimeoutMs: 5_000,
runRpcTimeoutMs: 5_000,
})
);
```
## API Reference
- `StubJobScheduler` — `registerCron`, `registerQueued`, `tickCron`,
`tickQueued` plus public `cron` / `queued` maps for inspection.
- `createJobsProxyHarness(opts) -> JobsProxyHarness` — full harness.
- `runJobsConformance(factory, throwOnFirstFailure = true)` — drives
the suite and reports per-case pass/fail.
- `JOBS_CONFORMANCE_CASES: ReadonlyArray<JobsConformanceCase>` — the
ordered scenario registry.
## Implementation Notes
`flush()` waits two macrotask turns: one for the worker-side
`registerJob` outbound to land on the main side, one for the main side's
`mainHandle.registerCron` call to settle before tests poke the
scheduler. The conformance scenarios assert via local `assertEq` and
`assertContains` helpers that throw plain `Error` instances; tests can
inspect `result.error` to discriminate between a scenario assertion and
a deeper failure (e.g. `JobsProxyError` raised during dispatch). The
`StubJobScheduler` deliberately exposes its internal `cron` / `queued`
maps so tests can verify that the round-trip actually reached the host
side, not just the wire.