@daloyjs/core 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -671,6 +671,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
671
671
  - `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
672
672
  - `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
673
673
  - `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
674
+ - Background jobs (queue-agnostic) at `@daloyjs/core/jobs`: durable `{ name, payload }` units that outlive the HTTP request and the process — `JobStore` SPI (all durability lives behind it; Redis/Postgres/SQS are user adapters, never core deps), `MemoryJobStore` for tests and single-process apps, `createJobQueue()` (name/queue charset allowlists, plain-JSON payloads capped at 64 KiB with prototype-pollution rejection, idempotency-key dedupe with conflict-on-reuse, delayed `runAt`, integer `priority`), and `createJobWorker()` (atomic claims with leases + fencing, auto-heartbeat plus `ctx.heartbeat()`, bounded concurrency, per-attempt `timeoutMs` via `AbortSignal`, retries with exponential backoff + full jitter, dead letters, graceful `stop()` drain, `runOnce()` for tests). Delivery is **at-least-once** — handlers must be idempotent (pass a key through to downstream APIs like Stripe's `Idempotency-Key`); `EnqueueOptions.idempotencyKey` collapses duplicate producers the way `idempotency()` collapses duplicate POSTs. `app.useJobs({ store, handlers, startWorker })` drains the worker on graceful shutdown and warns on a Memory store under production config; `app.cronEnqueue()` turns a cron tick into an idempotent enqueue so multi-replica crons do not double-fire. This is not a workflow/replay engine — no durable functions, no worlds, no `await sleep("7 days")`. Zero runtime dependencies.
674
675
  - `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
675
676
  - `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator`, `trustedHops`, or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Proxy-header identity is **spoof-resistant**: the client IP is read from the rightmost `X-Forwarded-For` entry (the one your proxy appended) via `resolveForwardedClientIp()`, so rotating spoofed left entries can neither evade strike accumulation nor frame a victim IP for banning; multi-hop chains declare their hop count with `trustedHops` (shared by `rateLimit()`, `loginThrottle()`, `concurrencyLimit()`, `geoBlock()`, `ipRestriction()`, `ipReputation()`, and `botGuard()`). For deployments where the origin itself is reachable, `trustedProxies` (a CIDR allowlist of your proxy peer addresses, accepted by every guard in that list) goes further: the immediate TCP peer is verified against the allowlist before any forwarded header is believed, so a direct-to-origin caller's spoofed `X-Forwarded-For` is ignored entirely — closing victim-IP framing and ban/limit evasion at the framework layer, and failing closed on peer-less edge platforms. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
676
677
  - `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
package/dist/app.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type SecureHeadersOptions } from "./middleware.js";
9
9
  import { type LoadSheddingOptions } from "./load-shedding.js";
10
10
  import { MetricsRegistry, type HttpMetricsOptions } from "./metrics.js";
11
11
  import { Scheduler, type TaskDefinition, type TaskHandler } from "./scheduler.js";
12
+ import { type JobHandler, type JobQueue, type JobQueueOptions, type JobStore, type JobWorker, type JobWorkerOptions } from "./jobs.js";
12
13
  import { type BehindProxyConfig } from "./conn-info.js";
13
14
  /** @internal Test-only helper to reset the latch between tests. */
14
15
  export declare function _resetCrashHandlersForTests(): void;
@@ -1001,6 +1002,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1001
1002
  * is tied to graceful shutdown.
1002
1003
  */
1003
1004
  private scheduler?;
1005
+ /**
1006
+ * Job queue / worker attached by {@link App.useJobs}. Both stay `undefined`
1007
+ * unless the app opts in — serverless isolates must never start a poll
1008
+ * loop implicitly.
1009
+ */
1010
+ private jobQueue?;
1011
+ private jobWorkerRef?;
1004
1012
  /** Idle-connection close hooks (adapter-registered, sync). */
1005
1013
  private idleConnectionCloseHooks;
1006
1014
  private pluginInstalledListeners;
@@ -1492,6 +1500,120 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1492
1500
  * is owned by the app.
1493
1501
  */
1494
1502
  get scheduledTasks(): Scheduler | undefined;
1503
+ /**
1504
+ * Attach a queue-agnostic background-job queue (and optionally a worker)
1505
+ * to this app. Opt-in by design: nothing in `new App()` starts a poll
1506
+ * loop, so serverless isolates that only ever *enqueue* never pay for (or
1507
+ * accidentally run) a worker.
1508
+ *
1509
+ * The queue answers *&ldquo;run this work somewhere, eventually&rdquo;* —
1510
+ * durable, retried, at-least-once units of `{ name, payload }` that
1511
+ * outlive the HTTP request and, with a durable {@link JobStore} adapter,
1512
+ * the process itself. This is not a workflow engine: there is no replay,
1513
+ * no durable function, no `await sleep("7 days")`.
1514
+ *
1515
+ * - `startWorker: true` creates a {@link JobWorker} over the same store,
1516
+ * starts it, and registers an `onClose` hook so in-flight jobs are
1517
+ * drained (then aborted past the grace period) on graceful shutdown.
1518
+ * Use it on long-lived Node/Bun/Deno processes only — never on Lambda /
1519
+ * Cloudflare Workers isolates.
1520
+ * - {@link MemoryJobStore} under production config logs a high-severity
1521
+ * warning (it is process-local and loses every job on restart); pass
1522
+ * `strictProduction: true` to refuse to boot instead.
1523
+ *
1524
+ * @example
1525
+ * ```ts
1526
+ * app.useJobs({
1527
+ * store: new MemoryJobStore(), // production: your Redis/Postgres JobStore
1528
+ * handlers: {
1529
+ * "email.welcome": async ({ job, signal }) => {
1530
+ * await sendEmail(job.payload, { signal });
1531
+ * },
1532
+ * },
1533
+ * startWorker: true,
1534
+ * });
1535
+ *
1536
+ * app.post("/users", contract, async (ctx) => {
1537
+ * const user = await db.insertUser(ctx.body);
1538
+ * await app.jobs!.enqueue({
1539
+ * name: "email.welcome",
1540
+ * payload: { userId: user.id },
1541
+ * idempotencyKey: jobIdempotencyKey({ tenant: ctx.state.tenant, name: "email.welcome", key: user.id }),
1542
+ * });
1543
+ * return { status: 201 as const, body: user };
1544
+ * });
1545
+ * ```
1546
+ *
1547
+ * @param opts - Store, optional handlers, worker and queue tuning.
1548
+ * @returns This `App` instance for chaining.
1549
+ * @throws {@link JobConfigError} when jobs are already configured, when
1550
+ * `startWorker` lacks handlers, or when `strictProduction` rejects a
1551
+ * {@link MemoryJobStore} under production config.
1552
+ * @since 1.3.0
1553
+ */
1554
+ useJobs(opts: {
1555
+ store: JobStore;
1556
+ handlers?: Record<string, JobHandler<any>>;
1557
+ startWorker?: boolean;
1558
+ worker?: Omit<JobWorkerOptions, "queue" | "handlers">;
1559
+ queue?: Omit<JobQueueOptions, "store">;
1560
+ strictProduction?: boolean;
1561
+ }): this;
1562
+ /**
1563
+ * The {@link JobQueue} attached by {@link App.useJobs}, or `undefined`
1564
+ * when jobs are not configured. Route handlers enqueue through this;
1565
+ * delivery is at-least-once, so handlers must be idempotent.
1566
+ *
1567
+ * @since 1.3.0
1568
+ */
1569
+ get jobs(): JobQueue | undefined;
1570
+ /**
1571
+ * The {@link JobWorker} created by {@link App.useJobs} with
1572
+ * `startWorker: true`, or `undefined`. Exposed for inspection
1573
+ * (`getState()`) and tests (`runOnce()`); the lifecycle is owned by the app.
1574
+ *
1575
+ * @since 1.3.0
1576
+ */
1577
+ get jobWorker(): JobWorker | undefined;
1578
+ /**
1579
+ * Register a cron task whose tick enqueues a job instead of running the
1580
+ * side effect in-process. This is the production posture for scheduled
1581
+ * work with global side effects (nightly reconciliation, invoice runs):
1582
+ * every replica may tick, but the deterministic idempotency key
1583
+ * `cron:{taskName}:{floor(scheduledFor / tickGranularity)}` collapses the
1584
+ * duplicate enqueues into one job, and exactly one worker claims it.
1585
+ *
1586
+ * `tickGranularity` is the task's `intervalMs` for interval schedules and
1587
+ * one minute for cron expressions (the finest cadence a cron expression
1588
+ * can fire), so two replicas ticking the same slot always derive the same
1589
+ * key. Use plain {@link App.cron} for process-local maintenance (cache
1590
+ * sweeps that must happen in *this* process); use `cronEnqueue` for work
1591
+ * that must happen once, cluster-wide, and survive a restart.
1592
+ *
1593
+ * @example
1594
+ * ```ts
1595
+ * app.cronEnqueue(
1596
+ * { name: "nightly-reconcile", cron: "0 2 * * *" },
1597
+ * { name: "ops.reconcile", payload: {} },
1598
+ * );
1599
+ * ```
1600
+ *
1601
+ * @param def - The task definition (schedule), same shape as {@link App.cron}.
1602
+ * @param job - The job to enqueue on each tick. `payload` defaults to
1603
+ * `{ scheduledFor: <ISO time of the tick slot> }`, floored to the same
1604
+ * slot the idempotency key uses so replicas ticking milliseconds apart
1605
+ * enqueue byte-identical payloads and dedupe instead of conflicting.
1606
+ * @returns This `App` instance for chaining.
1607
+ * @throws {@link JobConfigError} (`store_required`) when called before
1608
+ * {@link App.useJobs} — fail fast at registration, not at the first tick.
1609
+ * @since 1.3.0
1610
+ */
1611
+ cronEnqueue(def: TaskDefinition, job: {
1612
+ name: string;
1613
+ payload?: unknown;
1614
+ queue?: string;
1615
+ priority?: number;
1616
+ }): this;
1495
1617
  private registerHealthRoute;
1496
1618
  /**
1497
1619
  * Register a built-in receiver for CSP / Reporting API
package/dist/app.js CHANGED
@@ -14,6 +14,7 @@ import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
14
14
  import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
15
15
  import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
16
16
  import { Scheduler, } from "./scheduler.js";
17
+ import { createJobQueue, createJobWorker, JobConfigError, MemoryJobStore, } from "./jobs.js";
17
18
  import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
18
19
  import { assertBehindProxy } from "./conn-info.js";
19
20
  const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
@@ -428,6 +429,13 @@ export class App {
428
429
  * is tied to graceful shutdown.
429
430
  */
430
431
  scheduler;
432
+ /**
433
+ * Job queue / worker attached by {@link App.useJobs}. Both stay `undefined`
434
+ * unless the app opts in — serverless isolates must never start a poll
435
+ * loop implicitly.
436
+ */
437
+ jobQueue;
438
+ jobWorkerRef;
431
439
  /** Idle-connection close hooks (adapter-registered, sync). */
432
440
  idleConnectionCloseHooks = [];
433
441
  pluginInstalledListeners = [];
@@ -2020,6 +2028,167 @@ export class App {
2020
2028
  get scheduledTasks() {
2021
2029
  return this.scheduler;
2022
2030
  }
2031
+ /**
2032
+ * Attach a queue-agnostic background-job queue (and optionally a worker)
2033
+ * to this app. Opt-in by design: nothing in `new App()` starts a poll
2034
+ * loop, so serverless isolates that only ever *enqueue* never pay for (or
2035
+ * accidentally run) a worker.
2036
+ *
2037
+ * The queue answers *&ldquo;run this work somewhere, eventually&rdquo;* —
2038
+ * durable, retried, at-least-once units of `{ name, payload }` that
2039
+ * outlive the HTTP request and, with a durable {@link JobStore} adapter,
2040
+ * the process itself. This is not a workflow engine: there is no replay,
2041
+ * no durable function, no `await sleep("7 days")`.
2042
+ *
2043
+ * - `startWorker: true` creates a {@link JobWorker} over the same store,
2044
+ * starts it, and registers an `onClose` hook so in-flight jobs are
2045
+ * drained (then aborted past the grace period) on graceful shutdown.
2046
+ * Use it on long-lived Node/Bun/Deno processes only — never on Lambda /
2047
+ * Cloudflare Workers isolates.
2048
+ * - {@link MemoryJobStore} under production config logs a high-severity
2049
+ * warning (it is process-local and loses every job on restart); pass
2050
+ * `strictProduction: true` to refuse to boot instead.
2051
+ *
2052
+ * @example
2053
+ * ```ts
2054
+ * app.useJobs({
2055
+ * store: new MemoryJobStore(), // production: your Redis/Postgres JobStore
2056
+ * handlers: {
2057
+ * "email.welcome": async ({ job, signal }) => {
2058
+ * await sendEmail(job.payload, { signal });
2059
+ * },
2060
+ * },
2061
+ * startWorker: true,
2062
+ * });
2063
+ *
2064
+ * app.post("/users", contract, async (ctx) => {
2065
+ * const user = await db.insertUser(ctx.body);
2066
+ * await app.jobs!.enqueue({
2067
+ * name: "email.welcome",
2068
+ * payload: { userId: user.id },
2069
+ * idempotencyKey: jobIdempotencyKey({ tenant: ctx.state.tenant, name: "email.welcome", key: user.id }),
2070
+ * });
2071
+ * return { status: 201 as const, body: user };
2072
+ * });
2073
+ * ```
2074
+ *
2075
+ * @param opts - Store, optional handlers, worker and queue tuning.
2076
+ * @returns This `App` instance for chaining.
2077
+ * @throws {@link JobConfigError} when jobs are already configured, when
2078
+ * `startWorker` lacks handlers, or when `strictProduction` rejects a
2079
+ * {@link MemoryJobStore} under production config.
2080
+ * @since 1.3.0
2081
+ */
2082
+ useJobs(opts) {
2083
+ if (this.jobQueue !== undefined) {
2084
+ throw new JobConfigError("invalid_option", "app.useJobs() was called twice; jobs are already configured on this app.");
2085
+ }
2086
+ if (opts.store instanceof MemoryJobStore && this.isProduction()) {
2087
+ const message = "app.useJobs(): MemoryJobStore is not durable — jobs are lost on process restart " +
2088
+ "and invisible to other replicas. Supply a shared JobStore (Redis/Postgres/SQS adapter) " +
2089
+ "in production, or pass strictProduction: false to keep this warning-only posture.";
2090
+ if (opts.strictProduction === true) {
2091
+ throw new JobConfigError("invalid_option", message);
2092
+ }
2093
+ this.log.warn({ event: "jobs.memory_store_production", component: "jobs" }, message);
2094
+ }
2095
+ const logger = this.log.child({ component: "jobs" });
2096
+ const queue = createJobQueue({ store: opts.store, logger, ...opts.queue });
2097
+ let worker;
2098
+ if (opts.startWorker === true) {
2099
+ const handlers = opts.handlers;
2100
+ if (handlers === undefined || Object.keys(handlers).length === 0) {
2101
+ throw new JobConfigError("invalid_option", "app.useJobs(): startWorker: true requires a non-empty handlers map.");
2102
+ }
2103
+ worker = createJobWorker({
2104
+ ...opts.worker,
2105
+ queue,
2106
+ handlers,
2107
+ logger: opts.worker?.logger ?? logger,
2108
+ });
2109
+ worker.start();
2110
+ // Drain the worker during the post-drain close phase so an in-flight
2111
+ // job settles (or fails back to the queue) alongside other resources.
2112
+ const startedWorker = worker;
2113
+ this.onClose(() => startedWorker.stop());
2114
+ }
2115
+ this.jobQueue = queue;
2116
+ this.jobWorkerRef = worker;
2117
+ return this;
2118
+ }
2119
+ /**
2120
+ * The {@link JobQueue} attached by {@link App.useJobs}, or `undefined`
2121
+ * when jobs are not configured. Route handlers enqueue through this;
2122
+ * delivery is at-least-once, so handlers must be idempotent.
2123
+ *
2124
+ * @since 1.3.0
2125
+ */
2126
+ get jobs() {
2127
+ return this.jobQueue;
2128
+ }
2129
+ /**
2130
+ * The {@link JobWorker} created by {@link App.useJobs} with
2131
+ * `startWorker: true`, or `undefined`. Exposed for inspection
2132
+ * (`getState()`) and tests (`runOnce()`); the lifecycle is owned by the app.
2133
+ *
2134
+ * @since 1.3.0
2135
+ */
2136
+ get jobWorker() {
2137
+ return this.jobWorkerRef;
2138
+ }
2139
+ /**
2140
+ * Register a cron task whose tick enqueues a job instead of running the
2141
+ * side effect in-process. This is the production posture for scheduled
2142
+ * work with global side effects (nightly reconciliation, invoice runs):
2143
+ * every replica may tick, but the deterministic idempotency key
2144
+ * `cron:{taskName}:{floor(scheduledFor / tickGranularity)}` collapses the
2145
+ * duplicate enqueues into one job, and exactly one worker claims it.
2146
+ *
2147
+ * `tickGranularity` is the task's `intervalMs` for interval schedules and
2148
+ * one minute for cron expressions (the finest cadence a cron expression
2149
+ * can fire), so two replicas ticking the same slot always derive the same
2150
+ * key. Use plain {@link App.cron} for process-local maintenance (cache
2151
+ * sweeps that must happen in *this* process); use `cronEnqueue` for work
2152
+ * that must happen once, cluster-wide, and survive a restart.
2153
+ *
2154
+ * @example
2155
+ * ```ts
2156
+ * app.cronEnqueue(
2157
+ * { name: "nightly-reconcile", cron: "0 2 * * *" },
2158
+ * { name: "ops.reconcile", payload: {} },
2159
+ * );
2160
+ * ```
2161
+ *
2162
+ * @param def - The task definition (schedule), same shape as {@link App.cron}.
2163
+ * @param job - The job to enqueue on each tick. `payload` defaults to
2164
+ * `{ scheduledFor: <ISO time of the tick slot> }`, floored to the same
2165
+ * slot the idempotency key uses so replicas ticking milliseconds apart
2166
+ * enqueue byte-identical payloads and dedupe instead of conflicting.
2167
+ * @returns This `App` instance for chaining.
2168
+ * @throws {@link JobConfigError} (`store_required`) when called before
2169
+ * {@link App.useJobs} — fail fast at registration, not at the first tick.
2170
+ * @since 1.3.0
2171
+ */
2172
+ cronEnqueue(def, job) {
2173
+ const queue = this.jobQueue;
2174
+ if (queue === undefined) {
2175
+ throw new JobConfigError("store_required", "app.cronEnqueue() requires app.useJobs() first: attach a JobStore before scheduling job-producing ticks.");
2176
+ }
2177
+ const granularityMs = def.intervalMs !== undefined && def.intervalMs > 0 ? def.intervalMs : 60_000;
2178
+ return this.cron(def, async ({ name, scheduledFor }) => {
2179
+ const slot = Math.floor(scheduledFor.getTime() / granularityMs);
2180
+ await queue.enqueue({
2181
+ name: job.name,
2182
+ // Derive the default payload from the slot, not the raw tick time:
2183
+ // replicas tick a few milliseconds apart, and an identical key with
2184
+ // a different payload is a JobIdempotencyConflictError, not a dedup.
2185
+ payload: job.payload ?? { scheduledFor: new Date(slot * granularityMs).toISOString() },
2186
+ ...(job.queue !== undefined ? { queue: job.queue } : {}),
2187
+ ...(job.priority !== undefined ? { priority: job.priority } : {}),
2188
+ idempotencyKey: `cron:${encodeURIComponent(name)}:${slot}`,
2189
+ });
2190
+ });
2191
+ }
2023
2192
  registerHealthRoute(kind, opts, handler) {
2024
2193
  const isHealth = kind === "healthcheck";
2025
2194
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
package/dist/index.d.ts CHANGED
@@ -48,6 +48,8 @@ export { createWebhookSender, MemoryWebhookDeadLetterSink } from "./webhook-deli
48
48
  export type { WebhookEvent, WebhookSenderOptions, WebhookDeliveryResult, WebhookDeadLetter, WebhookDeadLetterSink, WebhookAttempt, } from "./webhook-delivery.js";
49
49
  export { Scheduler, CronParseError, parseCron, nextCronRun } from "./scheduler.js";
50
50
  export type { SchedulerOptions, SchedulerLogger, TimerFns, TaskDefinition, TaskHandler, TaskRunContext, TaskErrorInfo, TaskState, CronFields, } from "./scheduler.js";
51
+ export { createJobQueue, createJobWorker, computeBackoffMs, jobIdempotencyKey, MemoryJobStore, JobConfigError, JobIdempotencyConflictError, JobFatalError, JobTimeoutError, } from "./jobs.js";
52
+ export type { Job, JobStatus, JobStore, JobQueue, JobQueueOptions, JobWorker, JobWorkerOptions, JobContext, JobHandler, JobHandlerMap, JobBackoffOptions, EnqueueOptions, EnqueueResult, MemoryJobStoreOptions, JobConfigErrorCode, } from "./jobs.js";
51
53
  export { clientCertAuth, setClientCertificate, getClientCertificate, normalizePeerCertificate, parseForwardedClientCert, } from "./mtls.js";
52
54
  export type { ClientCertificate, ClientCertificateSource, ClientCertAuthOptions, ClientCertHeaderConfig, PeerCertificateLike, } from "./mtls.js";
53
55
  export { signMessage, signRequest, verifyMessage, verifyRequest, httpSignatureAuth, contentDigest, verifyContentDigest, DEFAULT_SIGNATURE_LABEL, DEFAULT_MAX_SIGNATURE_AGE_SECONDS, DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS, } from "./http-signatures.js";
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { fetchGuard, SsrfBlockedError } from "./fetch-guard.js";
26
26
  export { resilientFetch, CircuitBreaker, CircuitOpenError, FetchTimeoutError, } from "./fetch-resilience.js";
27
27
  export { createWebhookSender, MemoryWebhookDeadLetterSink } from "./webhook-delivery.js";
28
28
  export { Scheduler, CronParseError, parseCron, nextCronRun } from "./scheduler.js";
29
+ export { createJobQueue, createJobWorker, computeBackoffMs, jobIdempotencyKey, MemoryJobStore, JobConfigError, JobIdempotencyConflictError, JobFatalError, JobTimeoutError, } from "./jobs.js";
29
30
  export { clientCertAuth, setClientCertificate, getClientCertificate, normalizePeerCertificate, parseForwardedClientCert, } from "./mtls.js";
30
31
  export { signMessage, signRequest, verifyMessage, verifyRequest, httpSignatureAuth, contentDigest, verifyContentDigest, DEFAULT_SIGNATURE_LABEL, DEFAULT_MAX_SIGNATURE_AGE_SECONDS, DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS, } from "./http-signatures.js";
31
32
  export { autoBan, MemoryAutoBanStore, _resetAutoBanStoresForTests } from "./auto-ban.js";