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,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,88 @@
{
"name": "@suaveplan/api-jobs-worker-proxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"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"
}
},
"./main-side": {
"import": {
"types": "./dist/server/server.d.ts",
"default": "./dist/server/server.js"
}
},
"./worker-side": {
"import": {
"types": "./dist/client/client.d.ts",
"default": "./dist/client/client.js"
}
},
"./handler-bridge": {
"import": {
"types": "./dist/handler-bridge/handler-bridge.d.ts",
"default": "./dist/handler-bridge/handler-bridge.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/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"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:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,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";

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,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JOBS_PROXY_CODES, JobsProxyError } from "./error-codes.js";
describe("JOBS_PROXY error codes", () => {
it("exposes the full stable code registry", { timeout: 5000 }, () => {
expect(JOBS_PROXY_CODES.JOB_NOT_DECLARED).toBe(
"JOBS_PROXY.JOB_NOT_DECLARED"
);
expect(JOBS_PROXY_CODES.HANDLER_THREW).toBe("JOBS_PROXY.HANDLER_THREW");
expect(JOBS_PROXY_CODES.PROXY_DISPOSED).toBe(
"JOBS_PROXY.PROXY_DISPOSED"
);
expect(JOBS_PROXY_CODES.JOB_TIMEOUT).toBe("JOBS_PROXY.JOB_TIMEOUT");
expect(JOBS_PROXY_CODES.DUPLICATE_REGISTRATION).toBe(
"JOBS_PROXY.DUPLICATE_REGISTRATION"
);
});
it("constructs JobsProxyError with code + cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new JobsProxyError("JOBS_PROXY.HANDLER_THREW", "boom", {
cause,
});
expect(err).toBeInstanceOf(JobsProxyError);
expect(err.code).toBe("JOBS_PROXY.HANDLER_THREW");
expect(err.message).toBe("boom");
expect(err.cause).toBe(cause);
expect(err.name).toBe("JobsProxyError");
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new JobsProxyError("JOBS_PROXY.PROXY_DISPOSED", "gone");
expect(err.code).toBe("JOBS_PROXY.PROXY_DISPOSED");
});
});

View File

@@ -0,0 +1,54 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-jobs-worker-proxy`.
*
* Every public failure path raises a {@link JobsProxyError} carrying one of
* these `JOBS_PROXY.*` codes. Consumers branch on the literal value.
*/
export const JOBS_PROXY_CODES = defineCodes("JOBS_PROXY", [
/**
* Worker attempted to register a job whose name is not in the plugin
* manifest. Main side rejects at the boundary.
*/
"JOB_NOT_DECLARED",
/**
* The worker-side handler registered for a job threw or rejected when
* dispatched. Propagated back to the main scheduler.
*/
"HANDLER_THREW",
/**
* Proxy port has been closed; further registrations / dispatches are
* impossible.
*/
"PROXY_DISPOSED",
/**
* Worker-side handler dispatch exceeded the configured timeout.
*/
"JOB_TIMEOUT",
/**
* Same job name registered twice on the worker side — registration
* is idempotent-by-replace, but the duplicate is reported.
*/
"DUPLICATE_REGISTRATION",
] as const);
/** Union of every fully-qualified code in {@link JOBS_PROXY_CODES}. */
export type JobsProxyCode =
(typeof JOBS_PROXY_CODES)[keyof typeof JOBS_PROXY_CODES];
/**
* Typed error class. Extends {@link SuaveplanError} so callers may
* discriminate via either `instanceof JobsProxyError` or by code string.
*/
export class JobsProxyError extends SuaveplanError<JobsProxyCode> {
public constructor(
code: JobsProxyCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "JobsProxyError";
}
}

View File

@@ -0,0 +1,5 @@
export {
JOBS_PROXY_CODES,
type JobsProxyCode,
JobsProxyError,
} from "./error-codes.js";

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,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JobsProxyError } from "../error-codes/error-codes.js";
import {
HandlerRegistry,
registerJobRequestSchema,
runJobRequestSchema,
runWithTimeout,
} from "./handler-bridge.js";
describe("registerJobRequestSchema", () => {
it("accepts cron + queued kinds", { timeout: 5000 }, () => {
expect(
registerJobRequestSchema.parse({ kind: "cron", name: "x" })
).toEqual({ kind: "cron", name: "x" });
expect(
registerJobRequestSchema.parse({ kind: "queued", name: "y" })
).toEqual({ kind: "queued", name: "y" });
});
it("rejects unknown kind", { timeout: 5000 }, () => {
expect(() =>
registerJobRequestSchema.parse({ kind: "weird", name: "x" })
).toThrow();
});
it("rejects empty name", { timeout: 5000 }, () => {
expect(() =>
registerJobRequestSchema.parse({ kind: "cron", name: "" })
).toThrow();
});
});
describe("runJobRequestSchema", () => {
it("accepts cron without payload", { timeout: 5000 }, () => {
expect(runJobRequestSchema.parse({ name: "n", kind: "cron" })).toEqual({
name: "n",
kind: "cron",
});
});
it("accepts queued with payload", { timeout: 5000 }, () => {
expect(
runJobRequestSchema.parse({
name: "n",
kind: "queued",
payload: { a: 1 },
})
).toMatchObject({ name: "n", kind: "queued" });
});
});
describe("HandlerRegistry", () => {
it("stores + runs cron handlers", { timeout: 5000 }, async () => {
const reg = new HandlerRegistry();
let n = 0;
const existed = reg.setCron("a", async () => {
n += 1;
});
expect(existed).toBe(false);
expect(reg.hasCron("a")).toBe(true);
await reg.runCron("a");
expect(n).toBe(1);
expect(reg.sizes).toEqual({ cron: 1, queued: 0 });
});
it("stores + runs queued handlers with payload", {
timeout: 5000,
}, async () => {
const reg = new HandlerRegistry();
let received: unknown;
const existed = reg.setQueued("b", async (p) => {
received = p;
});
expect(existed).toBe(false);
expect(reg.hasQueued("b")).toBe(true);
await reg.runQueued("b", 42);
expect(received).toBe(42);
});
it("setCron returns existed=true on replace", { timeout: 5000 }, () => {
const reg = new HandlerRegistry();
reg.setCron("a", async () => {});
const existed = reg.setCron("a", async () => {});
expect(existed).toBe(true);
});
it("setQueued returns existed=true on replace", { timeout: 5000 }, () => {
const reg = new HandlerRegistry();
reg.setQueued("a", async () => {});
const existed = reg.setQueued("a", async () => {});
expect(existed).toBe(true);
});
it("runCron throws when missing", { timeout: 5000 }, async () => {
const reg = new HandlerRegistry();
await expect(reg.runCron("missing")).rejects.toBeInstanceOf(
JobsProxyError
);
});
it("runQueued throws when missing", { timeout: 5000 }, async () => {
const reg = new HandlerRegistry();
await expect(reg.runQueued("missing", null)).rejects.toBeInstanceOf(
JobsProxyError
);
});
it("clear wipes both maps", { timeout: 5000 }, () => {
const reg = new HandlerRegistry();
reg.setCron("a", async () => {});
reg.setQueued("b", async () => {});
reg.clear();
expect(reg.sizes).toEqual({ cron: 0, queued: 0 });
});
});
describe("runWithTimeout", () => {
it("returns on success with 0 timeout", { timeout: 5000 }, async () => {
await runWithTimeout(async () => {}, "x", 0);
});
it("wraps thrown errors into HANDLER_THREW with 0 timeout", {
timeout: 5000,
}, async () => {
await expect(
runWithTimeout(
async () => {
throw new Error("boom");
},
"x",
0
)
).rejects.toMatchObject({ code: "JOBS_PROXY.HANDLER_THREW" });
});
it("returns on success with positive timeout", {
timeout: 5000,
}, async () => {
await runWithTimeout(async () => {}, "x", 100);
});
it("times out long handler", { timeout: 5000 }, async () => {
await expect(
runWithTimeout(
() => new Promise<void>((r) => setTimeout(r, 50)),
"slow",
10
)
).rejects.toMatchObject({ code: "JOBS_PROXY.JOB_TIMEOUT" });
});
it("wraps thrown error with positive timeout", {
timeout: 5000,
}, async () => {
await expect(
runWithTimeout(
async () => {
throw new Error("nope");
},
"x",
100
)
).rejects.toMatchObject({ code: "JOBS_PROXY.HANDLER_THREW" });
});
it("re-throws JobsProxyError unchanged from inner", {
timeout: 5000,
}, async () => {
await expect(
runWithTimeout(
async () => {
throw new JobsProxyError("JOBS_PROXY.JOB_TIMEOUT", "inner");
},
"x",
100
)
).rejects.toMatchObject({ code: "JOBS_PROXY.JOB_TIMEOUT" });
});
});

View File

@@ -0,0 +1,136 @@
import z from "zod";
import { JobsProxyError } from "../error-codes/error-codes.js";
/**
* Zod schemas validating every request payload that crosses the
* worker ↔ main bridge. Both sides parse before acting; bad payloads
* surface as `JobsProxyError` with code `JOB_NOT_DECLARED` (unknown
* name) or are bubbled through the RPC layer's protocol error path.
*/
export const registerJobRequestSchema = z.object({
kind: z.union([z.literal("cron"), z.literal("queued")]),
name: z.string().min(1),
});
export const runJobRequestSchema = z.object({
name: z.string().min(1),
kind: z.union([z.literal("cron"), z.literal("queued")]),
payload: z.unknown().optional(),
});
export type RegisterJobRequest = z.infer<typeof registerJobRequestSchema>;
export type RunJobRequest = z.infer<typeof runJobRequestSchema>;
/**
* Registry of locally-stored handlers, keyed by the bridge-level
* `kind|name` tuple. The worker side records the handler closure
* supplied by the plugin; main side calls back over RPC when due.
*/
export class HandlerRegistry {
private readonly cron = new Map<string, () => Promise<void>>();
private readonly queued = new Map<
string,
(payload: unknown) => Promise<void>
>();
public hasCron(name: string): boolean {
return this.cron.has(name);
}
public hasQueued(name: string): boolean {
return this.queued.has(name);
}
public setCron(name: string, fn: () => Promise<void>): boolean {
const existed = this.cron.has(name);
this.cron.set(name, fn);
return existed;
}
public setQueued(
name: string,
fn: (payload: unknown) => Promise<void>
): boolean {
const existed = this.queued.has(name);
this.queued.set(name, fn);
return existed;
}
public async runCron(name: string): Promise<void> {
const fn = this.cron.get(name);
if (fn === undefined) {
throw new JobsProxyError(
"JOBS_PROXY.JOB_NOT_DECLARED",
`no cron handler registered for ${name}`
);
}
await fn();
}
public async runQueued(name: string, payload: unknown): Promise<void> {
const fn = this.queued.get(name);
if (fn === undefined) {
throw new JobsProxyError(
"JOBS_PROXY.JOB_NOT_DECLARED",
`no queued handler registered for ${name}`
);
}
await fn(payload);
}
public clear(): void {
this.cron.clear();
this.queued.clear();
}
public get sizes(): { cron: number; queued: number } {
return { cron: this.cron.size, queued: this.queued.size };
}
}
/**
* Run a worker-side handler with optional timeout. Wraps thrown errors
* (including async rejections) into a `JobsProxyError` of code
* `HANDLER_THREW`. A `0` timeout disables the timer.
*/
export async function runWithTimeout(
fn: () => Promise<void>,
name: string,
timeoutMs: number
): Promise<void> {
if (timeoutMs <= 0) {
try {
await fn();
return;
} catch (cause) {
throw new JobsProxyError(
"JOBS_PROXY.HANDLER_THREW",
`handler for job ${name} threw`,
{ cause }
);
}
}
let timer!: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(
new JobsProxyError(
"JOBS_PROXY.JOB_TIMEOUT",
`handler for job ${name} exceeded ${timeoutMs}ms`
)
);
}, timeoutMs);
});
try {
await Promise.race([fn(), timeoutPromise]);
} catch (err) {
if (err instanceof JobsProxyError) throw err;
throw new JobsProxyError(
"JOBS_PROXY.HANDLER_THREW",
`handler for job ${name} threw`,
{ cause: err }
);
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,26 @@
/**
* Wire-level method names exchanged between the worker-side proxy
* client and the main-side proxy server.
*
* - `registerCron` — worker → main; declares a cron handler for a job
* name. Main side validates the name against the plugin-manifest job
* declarations and wires it through to the host's
* `JobRegistrationHandle.registerCron`.
* - `registerQueued` — worker → main; same as above for queued jobs.
* - `runJob` — main → worker; main-side scheduler ticked a job, dispatch
* to the worker's registered handler.
*/
export type JobsProxyRpcMethod = "registerCron" | "registerQueued" | "runJob";
/** Wire shape of a `registerCron` / `registerQueued` request. */
export interface RegisterJobRequest {
readonly kind: "cron" | "queued";
readonly name: string;
}
/** Wire shape of a `runJob` request (main → worker dispatch). */
export interface RunJobRequest {
readonly name: string;
readonly kind: "cron" | "queued";
readonly payload?: unknown;
}

View File

@@ -0,0 +1,8 @@
export {
HandlerRegistry,
type RegisterJobRequest,
type RunJobRequest,
registerJobRequestSchema,
runJobRequestSchema,
runWithTimeout,
} from "./handler-bridge.js";

View File

@@ -0,0 +1,38 @@
// Worker-side: proxy client implementing JobRegistrationHandle.
export {
createJobsProxyClient,
type JobsProxyClient,
type JobsProxyClientOptions,
} from "./client/client.js";
// Error codes
export {
JOBS_PROXY_CODES,
type JobsProxyCode,
JobsProxyError,
} from "./error-codes/error-codes.js";
// Handler bridge — wire schemas + local registry + timeout runner
export {
HandlerRegistry,
type RegisterJobRequest,
type RunJobRequest,
registerJobRequestSchema,
runJobRequestSchema,
runWithTimeout,
} from "./handler-bridge/handler-bridge.js";
// Main-side: proxy server bound to a JobRegistrationHandle.
export {
createJobsProxyServer,
type JobsProxyServer,
type JobsProxyServerOptions,
} from "./server/server.js";
// Testing harness + conformance suite
export {
type CreateJobsProxyHarnessOptions,
createJobsProxyHarness,
emptyHandler,
JOBS_CONFORMANCE_CASES,
type JobsConformanceCase,
type JobsProxyHarness,
runJobsConformance,
StubJobScheduler,
} from "./testing/testing.js";

View File

@@ -0,0 +1,5 @@
export {
createJobsProxyServer,
type JobsProxyServer,
type JobsProxyServerOptions,
} from "./server.js";

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,114 @@
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createJobsProxyClient } from "../client/client.js";
import { JobsProxyError } from "../error-codes/error-codes.js";
import { StubJobScheduler } from "../testing/testing.js";
import { createJobsProxyServer } from "./server.js";
describe("createJobsProxyServer", () => {
it("registration after close does not increment registeredCount", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const server = createJobsProxyServer(ports.serverPort, scheduler, {
declaredJobs: [{ name: "x", kind: "cron" }],
});
const client = createJobsProxyClient(ports.clientPort);
server.close();
client.registerCron("x", async () => {});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
expect(server.registeredCount).toBe(0);
expect(scheduler.cron.size).toBe(0);
client.close();
ports.close();
});
it("propagates dispatch errors via onDispatchError", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const dispatchErrs: Array<{ name: string; kind: string }> = [];
const server = createJobsProxyServer(ports.serverPort, scheduler, {
declaredJobs: [{ name: "boom", kind: "cron" }],
onDispatchError: (_e, ctx) => dispatchErrs.push(ctx),
});
const client = createJobsProxyClient(ports.clientPort);
client.registerCron("boom", async () => {
throw new Error("inner failure");
});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
await expect(scheduler.tickCron("boom")).rejects.toBeTruthy();
expect(dispatchErrs[0]).toMatchObject({ name: "boom", kind: "cron" });
client.close();
server.close();
ports.close();
});
it("runRpcTimeoutMs is honored", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const server = createJobsProxyServer(ports.serverPort, scheduler, {
declaredJobs: [{ name: "slow", kind: "cron" }],
runRpcTimeoutMs: 5,
});
const client = createJobsProxyClient(ports.clientPort);
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(scheduler.tickCron("slow")).rejects.toBeTruthy();
client.close();
server.close();
ports.close();
});
it("registeredCount reflects accepted registrations", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const server = createJobsProxyServer(ports.serverPort, scheduler, {
declaredJobs: [
{ name: "a", kind: "cron" },
{ name: "b", kind: "queued" },
],
});
const client = createJobsProxyClient(ports.clientPort);
client.registerCron("a", async () => {});
client.registerQueued("b", async () => {});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
expect(server.registeredCount).toBe(2);
client.close();
server.close();
ports.close();
});
it("worker dispatching when proxy closed throws PROXY_DISPOSED", {
timeout: 5000,
}, async () => {
// Direct unit on dispatch path: register then immediately close
// and trigger the captured closure.
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const server = createJobsProxyServer(ports.serverPort, scheduler, {
declaredJobs: [{ name: "a", kind: "cron" }],
});
const client = createJobsProxyClient(ports.clientPort);
client.registerCron("a", async () => {});
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
server.close();
await expect(scheduler.tickCron("a")).rejects.toBeInstanceOf(
JobsProxyError
);
client.close();
ports.close();
});
});

View File

@@ -0,0 +1,139 @@
import type { capabilities } from "@suaveplan/api-types";
type JobRegistrationHandle = capabilities.JobRegistrationHandle;
import {
createRawWorkerRpcClient,
type PortLike,
serveWorkerRpc,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import { JobsProxyError } from "../error-codes/error-codes.js";
import {
type RegisterJobRequest,
registerJobRequestSchema,
} from "../handler-bridge/handler-bridge.js";
export interface JobsProxyServerOptions {
/**
* Whitelist of declared job names + kinds. Worker registrations for
* names NOT in this list are rejected with `JOB_NOT_DECLARED`. The
* host populates this from the plugin's manifest `jobs[]` array.
*/
readonly declaredJobs: ReadonlyArray<{
readonly name: string;
readonly kind: "cron" | "queued";
}>;
/**
* Optional per-RPC timeout when invoking the worker's handler. `0`
* disables; defaults to `0`.
*/
readonly runRpcTimeoutMs?: number;
/** Telemetry hook fired when a worker handler dispatch fails. */
readonly onDispatchError?: (
err: unknown,
ctx: { name: string; kind: "cron" | "queued" }
) => void;
}
export interface JobsProxyServer {
/** Tear down RPC server, RPC client, and registration tracking. */
close(reason?: string): void;
/** Number of registrations recorded since open. */
readonly registeredCount: number;
}
/**
* Main-side proxy. Receives `registerJob` RPC from the worker, validates
* the name against the manifest, then calls the supplied
* {@link JobRegistrationHandle} (the host's real scheduler). When the
* scheduler ticks, the bound closure marshals a `runJob` RPC back to the
* worker via the same port.
*/
export function createJobsProxyServer(
port: PortLike,
mainHandle: JobRegistrationHandle,
opts: JobsProxyServerOptions
): JobsProxyServer {
const declared = new Map<string, "cron" | "queued">();
for (const entry of opts.declaredJobs) {
declared.set(entry.name, entry.kind);
}
let registeredCount = 0;
let closed = false;
const rpc = createRawWorkerRpcClient(port);
let rpcServer: WorkerRpcServer | undefined;
rpcServer = serveWorkerRpc(port, {
registerJob: async (params: ReadonlyArray<unknown>) => {
const parsed: RegisterJobRequest = registerJobRequestSchema.parse(
params[0]
);
const declaredKind = declared.get(parsed.name);
if (declaredKind === undefined) {
throw new JobsProxyError(
"JOBS_PROXY.JOB_NOT_DECLARED",
`job ${parsed.name} is not declared in the plugin manifest`
);
}
if (declaredKind !== parsed.kind) {
throw new JobsProxyError(
"JOBS_PROXY.JOB_NOT_DECLARED",
`job ${parsed.name} declared as ${declaredKind} but registered as ${parsed.kind}`
);
}
registeredCount += 1;
if (parsed.kind === "cron") {
mainHandle.registerCron(parsed.name, async () => {
await dispatch(parsed.name, "cron", undefined);
});
} else {
mainHandle.registerQueued(
parsed.name,
async (payload: unknown) => {
await dispatch(parsed.name, "queued", payload);
}
);
}
return { ok: true } as const;
},
});
const dispatch = async (
name: string,
kind: "cron" | "queued",
payload: unknown
): Promise<void> => {
if (closed) {
throw new JobsProxyError(
"JOBS_PROXY.PROXY_DISPOSED",
"proxy server closed before dispatch"
);
}
const timeoutMs = opts.runRpcTimeoutMs ?? 0;
try {
await rpc.call(
"runJob",
[{ name, kind, payload }],
timeoutMs > 0 ? { timeoutMs } : undefined
);
} catch (err) {
opts.onDispatchError?.(err, { name, kind });
throw err;
}
};
return {
close(reason = "closed"): void {
if (closed) return;
closed = true;
rpc.close(reason);
rpcServer?.close(reason);
rpcServer = undefined;
},
get registeredCount(): number {
return registeredCount;
},
};
}

View File

@@ -0,0 +1,10 @@
export {
type CreateJobsProxyHarnessOptions,
createJobsProxyHarness,
emptyHandler,
JOBS_CONFORMANCE_CASES,
type JobsConformanceCase,
type JobsProxyHarness,
runJobsConformance,
StubJobScheduler,
} from "./testing.js";

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.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createJobsProxyHarness,
emptyHandler,
JOBS_CONFORMANCE_CASES,
runJobsConformance,
StubJobScheduler,
} from "./testing.js";
describe("StubJobScheduler", () => {
it("throws when ticking unknown cron", { timeout: 5000 }, async () => {
const s = new StubJobScheduler();
await expect(s.tickCron("nope")).rejects.toThrow();
});
it("throws when ticking unknown queued", { timeout: 5000 }, async () => {
const s = new StubJobScheduler();
await expect(s.tickQueued("nope", null)).rejects.toThrow();
});
});
describe("createJobsProxyHarness", () => {
it("wires harness end-to-end", { timeout: 5000 }, async () => {
const h = createJobsProxyHarness({
declaredJobs: [{ name: "a", kind: "cron" }],
handlerTimeoutMs: 1000,
runRpcTimeoutMs: 1000,
});
let n = 0;
h.client.registerCron("a", async () => {
n += 1;
});
await h.flush();
await h.scheduler.tickCron("a");
expect(n).toBe(1);
h.close();
});
});
describe("runJobsConformance", () => {
it("all conformance cases pass against the in-process harness", {
timeout: 5000,
}, async () => {
const factory = () =>
createJobsProxyHarness({
declaredJobs: [
{ name: "daily-rotate", kind: "cron" },
{ name: "send-email", kind: "queued" },
{ name: "ok", kind: "cron" },
],
});
const results = await runJobsConformance(factory);
for (const r of results) {
expect(r.ok).toBe(true);
}
expect(results.length).toBe(JOBS_CONFORMANCE_CASES.length);
});
it("collects failures when throwOnFirstFailure is false", {
timeout: 5000,
}, async () => {
// Empty declaredJobs → first case fails (not declared).
const factory = () => createJobsProxyHarness({ declaredJobs: [] });
const results = await runJobsConformance(factory, false);
const failed = results.find((r) => !r.ok);
expect(failed).toBeDefined();
});
it("throws on first failure by default", { timeout: 5000 }, async () => {
const factory = () => createJobsProxyHarness({ declaredJobs: [] });
await expect(runJobsConformance(factory)).rejects.toBeTruthy();
});
it("scenario failure when undeclared check passes silently", {
timeout: 5000,
}, async () => {
// Declare "not-in-manifest" — registration round-trips OK and
// the rejection hook is never fired, so scenario 3's
// assertContains throws.
const factory = () =>
createJobsProxyHarness({
declaredJobs: [
{ name: "daily-rotate", kind: "cron" },
{ name: "send-email", kind: "queued" },
{ name: "not-in-manifest", kind: "cron" },
{ name: "ok", kind: "cron" },
],
});
const results = await runJobsConformance(factory, false);
const failed = results.find((r) => !r.ok);
expect(failed).toBeDefined();
const fail = failed as { ok: false; error: unknown };
expect(String(fail.error)).toContain("rejection code");
});
it("emptyHandler resolves cleanly", { timeout: 5000 }, async () => {
await emptyHandler();
});
it("scenario failure on mismatched payload surfaces assertion message", {
timeout: 5000,
}, async () => {
// Replace the queued handler with one that swallows payload so
// the queued scenario's assertEq trips. To do this safely we'd
// need to mutate the harness — instead, exercise the same
// failure path by running a factory whose scheduler hijacks
// tickQueued to pass a wrong payload.
const factory = () => {
const h = createJobsProxyHarness({
declaredJobs: [
{ name: "daily-rotate", kind: "cron" },
{ name: "send-email", kind: "queued" },
{ name: "ok", kind: "cron" },
],
});
const original = h.scheduler.tickQueued.bind(h.scheduler);
h.scheduler.tickQueued = async (name: string) =>
original(name, { to: "wrong@addr" });
return h;
};
const results = await runJobsConformance(factory, false);
const failed = results.find((r) => !r.ok);
expect(failed).toBeDefined();
const fail = failed as { ok: false; error: unknown };
expect(String(fail.error)).toContain("queued payload");
});
});

View File

@@ -0,0 +1,256 @@
import type { capabilities } from "@suaveplan/api-types";
interface JobRegistrationHandle extends capabilities.JobRegistrationHandle {}
import {
createInProcessPortPair,
type InProcessPortPair,
} from "@suaveplan/api-worker-rpc";
import {
createJobsProxyClient,
type JobsProxyClient,
} from "../client/client.js";
import {
createJobsProxyServer,
type JobsProxyServer,
} from "../server/server.js";
/**
* In-memory stub of the host's job scheduler. Captures every
* registration and exposes `tick(name)` / `enqueueOne(name, payload)`
* so tests can simulate a scheduler firing without pulling in
* `@suaveplan/job-scheduler` as a dep.
*/
export class StubJobScheduler implements JobRegistrationHandle {
public readonly cron = new Map<string, () => Promise<void>>();
public readonly queued = new Map<
string,
(payload: unknown) => Promise<void>
>();
public registerCron(name: string, fn: () => Promise<void>): void {
this.cron.set(name, fn);
}
public registerQueued(
name: string,
fn: (payload: unknown) => Promise<void>
): void {
this.queued.set(name, fn);
}
public async tickCron(name: string): Promise<void> {
const fn = this.cron.get(name);
if (fn === undefined) {
throw new Error(`tickCron: no cron job registered as ${name}`);
}
await fn();
}
public async tickQueued(name: string, payload: unknown): Promise<void> {
const fn = this.queued.get(name);
if (fn === undefined) {
throw new Error(`tickQueued: no queued job registered as ${name}`);
}
await fn(payload);
}
}
/** Bundle of port-pair + scheduler + paired client/server. */
export interface JobsProxyHarness {
readonly ports: InProcessPortPair;
readonly scheduler: StubJobScheduler;
readonly client: JobsProxyClient;
readonly server: JobsProxyServer;
/** Wait for outstanding registration RPCs to flush. */
flush(): Promise<void>;
close(): void;
}
export interface CreateJobsProxyHarnessOptions {
readonly declaredJobs: ReadonlyArray<{
readonly name: string;
readonly kind: "cron" | "queued";
}>;
readonly handlerTimeoutMs?: number;
readonly runRpcTimeoutMs?: number;
}
/**
* Construct a fully-wired in-process harness: a {@link MessageChannel}
* pair connecting a worker-side {@link JobsProxyClient} to a main-side
* {@link JobsProxyServer} backed by a {@link StubJobScheduler}.
*/
export function createJobsProxyHarness(
opts: CreateJobsProxyHarnessOptions
): JobsProxyHarness {
const ports = createInProcessPortPair();
const scheduler = new StubJobScheduler();
const serverOpts: {
declaredJobs: CreateJobsProxyHarnessOptions["declaredJobs"];
runRpcTimeoutMs?: number;
} = { declaredJobs: opts.declaredJobs };
if (opts.runRpcTimeoutMs !== undefined) {
serverOpts.runRpcTimeoutMs = opts.runRpcTimeoutMs;
}
const server = createJobsProxyServer(
ports.serverPort,
scheduler,
serverOpts
);
const clientOpts: { handlerTimeoutMs?: number } = {};
if (opts.handlerTimeoutMs !== undefined) {
clientOpts.handlerTimeoutMs = opts.handlerTimeoutMs;
}
const client = createJobsProxyClient(ports.clientPort, clientOpts);
return {
ports,
scheduler,
client,
server,
async flush(): Promise<void> {
// RPC roundtrip is at most one microtask + one macrotask
// across `MessageChannel`. Two macrotask ticks suffice for
// even worst-case chained registers.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
},
close(): void {
client.close();
server.close();
ports.close();
},
};
}
/**
* Conformance scenarios. Local replacement for the
* `@suaveplan/job-scheduler/testing` `runJobsConformance` harness which
* does not yet exist in genesis. Each scenario asserts a specific
* worker ↔ main invariant.
*/
export interface JobsConformanceCase {
readonly title: string;
run(harness: JobsProxyHarness): Promise<void>;
}
export const JOBS_CONFORMANCE_CASES: ReadonlyArray<JobsConformanceCase> = [
{
title: "cron registration round-trips and ticks dispatch the worker handler",
async run(h) {
let fired = 0;
h.client.registerCron("daily-rotate", async () => {
fired += 1;
});
await h.flush();
await h.scheduler.tickCron("daily-rotate");
assertEq(fired, 1, "cron dispatch count");
},
},
{
title: "queued registration round-trips with payload",
async run(h) {
let received: unknown;
h.client.registerQueued("send-email", async (payload) => {
received = payload;
});
await h.flush();
await h.scheduler.tickQueued("send-email", { to: "x@y.z" });
assertEq(
(received as { to: string }).to,
"x@y.z",
"queued payload"
);
},
},
{
title: "non-declared job name surfaces JOB_NOT_DECLARED via onHandlerError",
async run(h) {
// Tear down the harness's default client and substitute one
// with an onHandlerError observer.
h.client.close();
const seen: string[] = [];
const errClient = createJobsProxyClient(h.ports.clientPort, {
onHandlerError: (err) => seen.push(err.code),
});
try {
errClient.registerCron("not-in-manifest", emptyHandler);
await new Promise<void>((r) => setTimeout(r, 0));
await new Promise<void>((r) => setTimeout(r, 0));
assertContains(
seen,
"JOBS_PROXY.JOB_NOT_DECLARED",
"rejection code"
);
} finally {
errClient.close();
}
},
},
];
/**
* No-op cron handler used by negative-path scenarios. Exported so
* conformance-harness coverage tests can invoke it directly.
*/
export async function emptyHandler(): Promise<void> {
/* no-op */
}
function assertEq<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
throw new Error(
`${label}: expected ${String(expected)}, got ${String(actual)}`
);
}
}
function assertContains(
arr: ReadonlyArray<string>,
value: string,
label: string
): void {
if (!arr.includes(value)) {
throw new Error(`${label}: expected ${value} in [${arr.join(",")}]`);
}
}
/**
* Drive the conformance suite end-to-end against the harness factory.
* Returns a per-case pass/fail report; throws on the first failure when
* `throwOnFirstFailure` is true (default).
*/
export async function runJobsConformance(
factory: () => JobsProxyHarness,
throwOnFirstFailure = true
): Promise<
ReadonlyArray<
| { title: string; ok: true }
| {
title: string;
ok: false;
error: unknown;
}
>
> {
const results: Array<
| { title: string; ok: true }
| { title: string; ok: false; error: unknown }
> = [];
for (const c of JOBS_CONFORMANCE_CASES) {
const harness = factory();
try {
await c.run(harness);
results.push({ title: c.title, ok: true });
} catch (error) {
results.push({ title: c.title, ok: false, error });
if (throwOnFirstFailure) {
harness.close();
throw error;
}
} finally {
harness.close();
}
}
return results;
}

View File

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

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createJobsProxyHarness,
runJobsConformance,
} from "../../src/testing/testing.js";
describe("runJobsConformance passes against the worker proxy", () => {
it("every scenario reports ok against the standard harness factory", {
timeout: 5000,
}, async () => {
const factory = () =>
createJobsProxyHarness({
declaredJobs: [
{ name: "daily-rotate", kind: "cron" },
{ name: "send-email", kind: "queued" },
{ name: "ok", kind: "cron" },
],
});
const results = await runJobsConformance(factory);
for (const r of results) {
expect(r.ok).toBe(true);
}
});
});

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.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",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});