Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createJobsProxyHarness } from "../testing/testing.js";
describe("createJobsProxyClient", () => {
it("registerCron round-trips, scheduler tick fires handler", {
timeout: 5000,
}, async () => {
const h = createJobsProxyHarness({
declaredJobs: [{ name: "rotate-keys", kind: "cron" }],
});
let fired = 0;
h.client.registerCron("rotate-keys", async () => {
fired += 1;
});
await h.flush();
expect(h.server.registeredCount).toBe(1);
await h.scheduler.tickCron("rotate-keys");
expect(fired).toBe(1);
h.close();
});
it("registerQueued round-trips with payload", {
timeout: 5000,
}, async () => {
const h = createJobsProxyHarness({
declaredJobs: [{ name: "send-email", kind: "queued" }],
});
let received: unknown;
h.client.registerQueued("send-email", async (payload) => {
received = payload;
});
await h.flush();
await h.scheduler.tickQueued("send-email", { to: "a@b" });
expect(received).toEqual({ to: "a@b" });
h.close();
});
it("non-declared job name triggers JOB_NOT_DECLARED via onHandlerError", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const h = createJobsProxyHarness({
declaredJobs: [{ name: "ok", kind: "cron" }],
});
h.client.close();
// Build a fresh client wired to the onHandlerError hook for this case
// (the harness's default client did not subscribe).
const { createJobsProxyClient } = await import("./client.js");
const errClient = createJobsProxyClient(h.ports.clientPort, {
onHandlerError: (err) => seen.push(err.code),
});
errClient.registerCron("not-in-manifest", async () => {});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
expect(seen).toContain("JOBS_PROXY.JOB_NOT_DECLARED");
errClient.close();
h.server.close();
h.ports.close();
});
it("declared queued name but registered as cron is rejected", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const h = createJobsProxyHarness({
declaredJobs: [{ name: "x", kind: "queued" }],
});
h.client.close();
const { createJobsProxyClient } = await import("./client.js");
const errClient = createJobsProxyClient(h.ports.clientPort, {
onHandlerError: (err) => seen.push(err.code),
});
errClient.registerCron("x", async () => {});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
expect(seen).toContain("JOBS_PROXY.JOB_NOT_DECLARED");
errClient.close();
h.server.close();
h.ports.close();
});
it("worker handler that throws surfaces via onHandlerError", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const h = createJobsProxyHarness({
declaredJobs: [{ name: "boom", kind: "cron" }],
});
h.client.close();
const { createJobsProxyClient } = await import("./client.js");
const client = createJobsProxyClient(h.ports.clientPort, {
onHandlerError: (err) => seen.push(err.code),
});
client.registerCron("boom", async () => {
throw new Error("nope");
});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
// Now tick — main fires, RPC to worker, worker handler throws,
// server.tickCron rejection bubbles back through Promise.
await expect(h.scheduler.tickCron("boom")).rejects.toBeTruthy();
expect(seen).toContain("JOBS_PROXY.HANDLER_THREW");
client.close();
h.server.close();
h.ports.close();
});
it("handlerTimeoutMs aborts long-running handlers", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const h = createJobsProxyHarness({
declaredJobs: [{ name: "slow", kind: "cron" }],
});
h.client.close();
const { createJobsProxyClient } = await import("./client.js");
const client = createJobsProxyClient(h.ports.clientPort, {
handlerTimeoutMs: 5,
onHandlerError: (err) => seen.push(err.code),
});
client.registerCron(
"slow",
() => new Promise<void>((r) => setTimeout(r, 200))
);
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
await expect(h.scheduler.tickCron("slow")).rejects.toBeTruthy();
expect(seen).toContain("JOBS_PROXY.JOB_TIMEOUT");
client.close();
h.server.close();
h.ports.close();
});
it("sizes reflects registrations", { timeout: 5000 }, async () => {
const h = createJobsProxyHarness({
declaredJobs: [
{ name: "a", kind: "cron" },
{ name: "b", kind: "queued" },
],
});
h.client.registerCron("a", async () => {});
h.client.registerQueued("b", async () => {});
expect(h.client.sizes).toEqual({ cron: 1, queued: 1 });
await h.flush();
h.close();
});
it("close is idempotent", { timeout: 5000 }, () => {
const h = createJobsProxyHarness({ declaredJobs: [] });
h.client.close();
h.client.close();
h.server.close();
h.server.close();
h.ports.close();
});
});

View File

@@ -0,0 +1,128 @@
import type { capabilities } from "@suaveplan/api-types";
/**
* Local alias for the api-types capability slot. The interface itself
* is namespace-exported (`capabilities.JobRegistrationHandle`); we lift
* the two methods into an inline interface here so this package can
* extend it without consumers needing to import the namespace alias.
*/
export interface JobRegistrationHandle
extends capabilities.JobRegistrationHandle {}
import {
createRawWorkerRpcClient,
type PortLike,
serveWorkerRpc,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import { JobsProxyError } from "../error-codes/error-codes.js";
import {
HandlerRegistry,
type RunJobRequest,
runJobRequestSchema,
runWithTimeout,
} from "../handler-bridge/handler-bridge.js";
export interface JobsProxyClientOptions {
/**
* Optional per-handler dispatch timeout in milliseconds. `0` disables.
* Default `0` (relies on the main-side scheduler's own timeout).
*/
readonly handlerTimeoutMs?: number;
/**
* Hook invoked when the worker-side handler throws or times out.
* Useful for telemetry; the error is also reported back to the main
* side via the RPC response.
*/
readonly onHandlerError?: (
err: JobsProxyError,
ctx: { name: string; kind: "cron" | "queued" }
) => void;
}
/**
* Worker-side proxy. Exposes the `JobRegistrationHandle` surface that
* plugin authors call. Each `registerCron`/`registerQueued` call:
*
* 1. Stores the handler closure in a local {@link HandlerRegistry}.
* 2. Marshals a `registerCron`/`registerQueued` RPC to main, which
* wires the registration into the host's job scheduler.
*
* The same proxy serves inbound `runJob` RPC frames from main, dispatching
* to the registered handler.
*/
export interface JobsProxyClient extends JobRegistrationHandle {
/** Tear down both RPC client + inbound-dispatch server. */
close(reason?: string): void;
/** Active registration counts (for tests + observability). */
readonly sizes: { cron: number; queued: number };
}
export function createJobsProxyClient(
port: PortLike,
opts: JobsProxyClientOptions = {}
): JobsProxyClient {
const registry = new HandlerRegistry();
const rpc = createRawWorkerRpcClient(port);
const timeoutMs = opts.handlerTimeoutMs ?? 0;
let server: WorkerRpcServer | undefined;
server = serveWorkerRpc(port, {
runJob: async (params: ReadonlyArray<unknown>) => {
const parsed: RunJobRequest = runJobRequestSchema.parse(params[0]);
const dispatch =
parsed.kind === "cron"
? () => registry.runCron(parsed.name)
: () => registry.runQueued(parsed.name, parsed.payload);
try {
await runWithTimeout(dispatch, parsed.name, timeoutMs);
return { ok: true } as const;
} catch (err) {
// `runWithTimeout` always wraps into a JobsProxyError.
const jpe = err as JobsProxyError;
opts.onHandlerError?.(jpe, {
name: parsed.name,
kind: parsed.kind,
});
throw jpe;
}
},
});
const fireRegister = (kind: "cron" | "queued", name: string): void => {
rpc.call("registerJob", [{ kind, name }]).catch((err: unknown) => {
// Reconstructed errors arrive as SuaveplanError instances
// with the wire `code` preserved. Wrap into a fresh
// JobsProxyError for caller observability.
const jpe = new JobsProxyError(
"JOBS_PROXY.JOB_NOT_DECLARED",
`registration for ${name} rejected by main`,
{ cause: err }
);
opts.onHandlerError?.(jpe, { name, kind });
});
};
return {
registerCron(name: string, fn: () => Promise<void>): void {
registry.setCron(name, fn);
fireRegister("cron", name);
},
registerQueued(
name: string,
fn: (payload: unknown) => Promise<void>
): void {
registry.setQueued(name, fn);
fireRegister("queued", name);
},
close(reason = "closed"): void {
rpc.close(reason);
server?.close(reason);
server = undefined;
registry.clear();
},
get sizes(): { cron: number; queued: number } {
return registry.sizes;
},
};
}

View File

@@ -0,0 +1,5 @@
export {
createJobsProxyClient,
type JobsProxyClient,
type JobsProxyClientOptions,
} from "./client.js";