@broberg/cron

中文文档
0.4.0发布于 2026/10/31MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 2134

Typed self-service client for cronjobs.webhouse.net — register + manage HTTP cron jobs over the Bearer-authed /api/jobs API (create/list/update/delete/pause/run), with a per-repo scoped token and per-job target headers kept server-side (never in the URL o

原始使用文档
文档目录10 节
  1. Usage
  2. Minting tokens (orchestrator only)
  3. Things the types cannot tell you
  4. A rejected token — the client never follows a redirect (sin…
  5. Timestamps are ISO strings (0.4.0 — a type change)
  6. externalId uses a COLON — repo:job-name
  7. connectTimeout defaults to null , and that is deliberate
  8. A partial update no longer resets what you left out — but s…
  9. Is this client still current?
  10. API

@broberg/cron

Typed self-service client for cronjobs.webhouse.net — the fleet's hosted HTTP-cron. Register and manage scheduled jobs in a few typed calls instead of hitting the NextAuth login wall or hand-rolling a scheduler.

  • Dependency-free. Raw fetch over the Bearer-authed /api/jobs API, so it runs in Node, Bun and edge alike.
  • Types generated from the service OpenAPI (pnpm gen → src/schema.ts), so the client stays byte-aligned with the contract — a spec change is a type error.
  • Per-repo scoped token. A scoped CRONJOBS_API_TOKEN only ever sees and touches its own repo's jobs (cross-scope → 404).
  • Secrets stay server-side. Put a target secret in a per-job headers entry; it's stored on the cron service and forwarded verbatim on each tick — never in the URL or a log. But until cronjobs F013 ships, headers are stored in plaintext and returned UNMASKED by GET /api/jobs, /api/jobs/:id and /api/jobs/export — anyone with access to the job can read the secret. Use a least-privilege, short-lived token.

Not yet on npm. v0.1.0 bootstrap-publish is pending Christian's go + Trusted Publisher (the standard @broberg/* ship path). Built against the live contract; cronjobs.webhouse.net/api/openapi.json is the source of truth.

pnpm add @broberg/cron

Usage

import { createCron } from "@broberg/cron";

// token defaults to process.env.CRONJOBS_API_TOKEN; baseUrl to cronjobs.webhouse.net
const cron = createCron();

// Register a job. Pass a stable `externalId` so re-running a deploy UPSERTS the
// same job (no duplicate) instead of creating a new one — this is also how you
// UPDATE a job (re-create with the same externalId), so you never track ids.
// The target secret rides in `headers` (object → stored JSON), never in the URL.
const job = await cron.createJob({
  name: "xrt81 push-tick",
  schedule: "*/10 * * * *", // every 10 minutes
  url: "https://xrt81.com/api/push/tick",
  method: "POST",
  headers: { Authorization: "Bearer <PUSH_TICK_SECRET>" },
  externalId: "xrt81:push-tick",
});

await cron.listJobs({ tag: "push" });
await cron.pauseJob(job.id);   // idempotent — no-op if already paused
await cron.resumeJob(job.id);
await cron.runJob(job.id);     // fire once now → Execution
await cron.getExecutions(job.id); // recent run history
await cron.deleteJob(job.id);

Every method throws a typed CronError ({ status, code?, message, details? }, code ∈ unauthorized | forbidden | not_found | validation_error | invalid_cron) on a non-2xx response, so a failed registration surfaces loudly.

Minting tokens (orchestrator only)

// Requires a session/admin token (a scoped token gets 403). Omit `scope` for a
// full-access token (session only). The plaintext `key` is returned ONCE.
const { key } = await cron.mintKey({ name: "xrt81 production", scope: "xrt81" });

The admin token that mints per-repo tokens lives with buddy (one audited privileged path); ordinary repos consume a scoped token they were provisioned.

Things the types cannot tell you

A rejected token — the client never follows a redirect (since 0.4.0, F041.4)

Today (measured 30/10 2026, after cronjobs F017.1, sha 34094ce): every /api/* call with a wrong token, a non-cj_ token or no header answers 401 with {"error":{"code":"unauthorized","message":"Authentication required"}} and no Location. That reaches you as a normal CronError with status: 401.

Why the client still guards against redirects: until that fix, a non-cj_ token or a missing header got 307 → /login. Up to 0.2.0 the client followed it, got a 200 with 12 kB of login HTML, and returned that page typed as Job[] — jobs.length was 12057, a number that looks like an answer (measured 15/9). A service can regress, so the client now never follows a redirect:

  • A redirect to a login page throws CronError with the real status, location, and a message saying the token was missing or not accepted.
  • Any other redirect (e.g. a 308 to a moved endpoint) throws with its status and target — and does not claim the token is bad.
  • A 200 whose body is not JSON throws cron_not_json naming the content-type and the first ~80 characters, instead of being returned cast to your type.

Timestamps are ISO strings (0.4.0 — a type change)

lastRunAt, nextRunAt, createdAt, updatedAt and the execution times (startedAt, completedAt) are ISO-8601 strings on the wire, e.g. "2026-10-30T17:00:00.628Z". The service's own spec said "integer, Unix ms" until 30/10 2026 (cronjobs a1b3a75 fixed it), so 0.3.x and earlier typed them as number while the value was always a string. If you did arithmetic on them, it was already wrong; the types now say so. Parse with new Date(job.lastRunAt).

externalId uses a COLON — repo:job-name

Thirteen of the fifteen jobs on the fleet follow it, and nothing on either side normalises it. So a deviation does not fail — it silently creates a duplicate job instead of updating the one you meant.

From the call site, "the upsert missed" and "the upsert updated" look identical. Only 201 versus 200 separates them, and nobody reads that.

Pick the convention and stay on it. This client deliberately does not rewrite your identifier for you: silently normalising someone's id is how you create the duplicate you were trying to prevent.

connectTimeout defaults to null, and that is deliberate

Two different questions, which timeout alone used to answer at once:

timeouthow long the work may take
connectTimeouthow long we wait for a response to begin

fetch() resolves at the response headers, so for a job that computes first and answers afterwards, time-to-header is work time — a short default would kill exactly those jobs. Set it only when your endpoint answers promptly.

Where it does apply the gain is real: buddy's ingest job answers in 201 ms, and with timeout: 120000 it hung for the full two minutes through a 16-second outage, burning its whole retry budget on one dead attempt. Long patience makes a job more fragile, not more robust.

A partial update no longer resets what you left out — but send everything anyway

Server-side defaults now apply only on creation, so a minimal spec is safe. tags: undefined keeps them; tags: [] clears them.

It was not always so, and the bug hit the exact pattern we recommend — an idempotent re-registration by externalId, which every integration runs on every deploy. Each run silently cleared all tags, reset timeout/retry/timezone, turned a POST job into a GET, and re-enabled a job someone had deliberately paused.

Fixed upstream. Still send your full configuration, so your job does not depend on server behaviour you cannot see from here.

Is this client still current?

npm run check:drift

Fetches the published contract, regenerates, and compares. Three outcomes, never two:

exit
✓0in sync
✗ DRIFTED1regenerate with npm run gen — it names the first line that differs
? COULD NOT ASK2the spec could not be reached or parsed

It runs as part of npm test, where drift blocks and "could not ask" does not. That asymmetry is on purpose: a check that goes red on a flaky network is a check that gets switched off, and the real drift gets switched off with it. The trade is that an unreachable spec is reported rather than enforced — so read the line, don't just read the exit code.

Why it exists: on 2026-08-28 four public-API changes landed in a single day, two of which had already made these types wrong — and the only reason we found out is that somebody on the other side chose to write to us. A signal that exists only while a person remembers to send it is not a signal.

API

  • createCron(config?) → CronClient — config: { token?, baseUrl?, fetch? }.
  • createJob(spec) (upsert via externalId) · getJob(id) · listJobs(filter?) · deleteJob(id) · pauseJob(id) · resumeJob(id) · toggleJob(id) · runJob(id) · getExecutions(id) · getStatus(id) · mintKey({name, scope?}).
  • CronError — thrown on any non-2xx (and on a missing token/fetch).

Owned + published by broberg-ai/components (epic F041). The cron service (scoped tokens, self-service mint, idempotency) is owned by the cronjobs repo. MIT.

版本与发布记录

@broberg/cron 共发布 3 个版本,首次发布于 2026/06/24;过去 12 个月发布了 3 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.0latest2026/10/318 天前
0.2.02026/08/282 个月前
0.1.02026/06/244 个月前

关于 @broberg/cron 的常见问题

@broberg/cron 是什么?

Typed self-service client for cronjobs.webhouse.net — register + manage HTTP cron jobs over the Bearer-authed /api/jobs API (create/list/update/delete/pause/run), with a per-repo scoped token and per-job target headers kept server-side (never in the URL o

如何安装 @broberg/cron?

在项目目录运行 npm install @broberg/cron,或使用 pnpm add / yarn add / bun add。

@broberg/cron 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/cron 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/cron 可以商用吗?

@broberg/cron 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/cron 的下载量有多少?

根据 npm 官方下载统计,@broberg/cron 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,134 次,日均约 71 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/cron 还在维护吗?

最新版本 0.4.0 发布于 2026/10/31(8 天前);至今共发布 3 个版本,其中过去 12 个月发布了 3 个;首次发布于 2026/06/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。