Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createJobsProxyClient,
|
||||
type JobsProxyClient,
|
||||
type JobsProxyClientOptions,
|
||||
} from "./client.js";
|
||||
Reference in New Issue
Block a user