Initial Commit
This commit is contained in:
70
packages/api-stack/api/api-jobs-worker-proxy/README.md
Normal file
70
packages/api-stack/api/api-jobs-worker-proxy/README.md
Normal 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
|
||||
```
|
||||
|
||||
88
packages/api-stack/api/api-jobs-worker-proxy/package.json
Normal file
88
packages/api-stack/api/api-jobs-worker-proxy/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
JOBS_PROXY_CODES,
|
||||
type JobsProxyCode,
|
||||
JobsProxyError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
HandlerRegistry,
|
||||
type RegisterJobRequest,
|
||||
type RunJobRequest,
|
||||
registerJobRequestSchema,
|
||||
runJobRequestSchema,
|
||||
runWithTimeout,
|
||||
} from "./handler-bridge.js";
|
||||
38
packages/api-stack/api/api-jobs-worker-proxy/src/index.ts
Normal file
38
packages/api-stack/api/api-jobs-worker-proxy/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createJobsProxyServer,
|
||||
type JobsProxyServer,
|
||||
type JobsProxyServerOptions,
|
||||
} from "./server.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type CreateJobsProxyHarnessOptions,
|
||||
createJobsProxyHarness,
|
||||
emptyHandler,
|
||||
JOBS_CONFORMANCE_CASES,
|
||||
type JobsConformanceCase,
|
||||
type JobsProxyHarness,
|
||||
runJobsConformance,
|
||||
StubJobScheduler,
|
||||
} from "./testing.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* §16 conformance proof — the worker-proxy package consumes the
|
||||
* `@suaveplan/api-worker-rpc` contract directly (client + server +
|
||||
* MessagePort transport). This test exercises the canonical
|
||||
* `runWorkerRpcConformance` harness with a minimal handler set to
|
||||
* prove the consumer is wire-compatible end-to-end (request frame,
|
||||
* response frame, abort propagation) using the same port transport
|
||||
* the package builds on top of in `src/main-side` / `src/worker-side`.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
|
||||
|
||||
const handles: Array<{ dispose(): void }> = [];
|
||||
afterEach(() => {
|
||||
while (handles.length > 0) handles.pop()?.dispose();
|
||||
});
|
||||
|
||||
describe("api-worker-rpc contract conformance (consumer-side)", () => {
|
||||
it("round-trips a request through the canonical harness", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({
|
||||
echo: (params: ReadonlyArray<unknown>) => params[0],
|
||||
}));
|
||||
handles.push(h);
|
||||
const result = await h.client.call<number>("echo", [42]);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("surfaces UNKNOWN_METHOD from the server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
handles.push(h);
|
||||
await expect(h.client.call("missing", [])).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-jobs-worker-proxy/vite.config.ts
Normal file
30
packages/api-stack/api/api-jobs-worker-proxy/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user