@cronvello/sdk 0.2.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,62 @@
3
3
  All notable changes to `@cronvello/sdk` are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/) (pre-1.0: minor-feature additions ship as patch releases).
5
5
 
6
+ ## 0.3.0
7
+
8
+ **Credentials are no longer part of defining jobs.** 0.2.0 made the SDK run locally with no account,
9
+ but `defineCronvello()` still demanded an `apiKey`, an `appUrl` and a `dispatchSecret` before it
10
+ would construct anything. So the documented way to try the local engine was to invent a fake API
11
+ key for a scheduler that never makes a network call. That was real config standing in for no real
12
+ constraint, and it landed on exactly the people the local mode is meant to attract.
13
+
14
+ ### Changed
15
+
16
+ - **`apiKey`, `appUrl` and `dispatchSecret` are now optional.** `defineCronvello({ appName, jobs })`
17
+ is a complete, valid app. `cronvello dev`, `dev()` and `trigger()` work on it unchanged.
18
+ - **The hosted side validates where it is used, not at define time.** `sync()`, `run()`, `client`,
19
+ `dispatchUrl`, `expressHandler()` and `nextHandler()` each check what they actually need and throw
20
+ a `CronvelloConfigError` naming every missing field. `client` is now built on first access rather
21
+ than eagerly.
22
+ - A *present* but malformed value is still rejected at define time (`appUrl` must be absolute
23
+ http(s), `dispatchSecret` at least 16 chars). This removes a requirement, not a check.
24
+
25
+ ### Added
26
+
27
+ - **`app.isCloudConfigured`** — true when `apiKey`, `appUrl` and `dispatchSecret` are all present.
28
+ - **A rebuilt local dashboard.** The old page answered "what are my jobs" but not "is this healthy",
29
+ so you had to read a log to find out. It now opens on a summary (jobs, runs, succeeded, failed,
30
+ next fire) and a **timeline**: one lane per job, past runs plotted against upcoming fires around a
31
+ moving now-line. A schedule that is wrong shows up there immediately.
32
+ - Run detail now includes the **error message** or the **returned value**, which is the thing you
33
+ opened the panel for.
34
+ - The feed can be paused, and says how many events it is holding back.
35
+ - Rows are keyboard-reachable, Escape closes the drawer and returns focus.
36
+ - The page is driven by one `/api/state` snapshot instead of a fetch per event, and the timeline
37
+ advances by translating a fixed track rather than repositioning every mark each second.
38
+ - **`GET /api/state`** on the dashboard server: jobs (with last run and upcoming fires), run history
39
+ and engine state in one response.
40
+ - **`engine.startedAt`** and **`engine.running`** on `LocalEngine`.
41
+
42
+ ### Fixed
43
+
44
+ - The dashboard's job table stayed on "Loading" forever when the registry was empty, because
45
+ "no jobs" and "not loaded yet" looked identical to it.
46
+ - A `scheduled` event carries the *next fire time* as its timestamp, which the feed printed in its
47
+ clock column and so appeared to jump forward in time. It now reads "scheduled for HH:MM:SS" and
48
+ the clock column is strictly arrival time.
49
+
50
+ ### Security
51
+
52
+ - An app without a `dispatchSecret` never runs a job from an HTTP request. `expressHandler()` and
53
+ `nextHandler()` throw at mount time rather than exposing a route, and `handle()` answers `500`
54
+ ("Dispatch is not configured") before touching the body. There is no path in which a missing
55
+ secret degrades into accepting unauthenticated dispatches.
56
+
57
+ ### Compatibility
58
+
59
+ Backwards compatible: every config valid in 0.2.x is still valid and behaves identically. Minor
60
+ rather than patch because the public config type widened and `client` / `dispatchUrl` became lazy.
61
+
6
62
  ## 0.2.1
7
63
 
8
64
  - Move the canonical SDK source to the public
package/README.md CHANGED
@@ -4,14 +4,45 @@
4
4
  [![CI](https://github.com/niccasWilliams/cronvello-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/niccasWilliams/cronvello-sdk/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/@cronvello/sdk)](./LICENSE)
6
6
 
7
- **Code-first cron jobs for [Cronvello](https://cronvello.com).** Define your scheduled jobs in
8
- your codebase, and the SDK keeps them in sync with Cronvello and runs them — **no dashboard
9
- required**. Your jobs always follow your code.
7
+ **Code-first cron jobs.** Declare your scheduled jobs in your codebase and run them: with a real
8
+ scheduler on your own machine, and optionally with [Cronvello](https://cronvello.com) hosting them.
9
+ **No dashboard required.** Your jobs always follow your code.
10
+
11
+ ## Try it without an account
12
+
13
+ Three steps, no signup, nothing leaves your machine:
10
14
 
11
15
  ```bash
12
16
  npm i @cronvello/sdk
13
17
  ```
14
18
 
19
+ ```ts
20
+ // cronvello.config.ts
21
+ import { defineCronvello, every } from "@cronvello/sdk";
22
+
23
+ export default defineCronvello({
24
+ appName: "my-app",
25
+ jobs: {
26
+ heartbeat: { schedule: every("10s"), handler: async () => console.log("beat") },
27
+ },
28
+ });
29
+ ```
30
+
31
+ ```bash
32
+ npx cronvello dev
33
+ ```
34
+
35
+ That starts an actual scheduler loop: each next fire time computed from the cron expression,
36
+ timezone- and DST-aware, with overlap protection, per-run timeouts and retry with backoff. It makes
37
+ no network calls. This part is MIT licensed and works standalone. See
38
+ [Local development](#local-development-no-account--cronvello-dev) for the full output.
39
+
40
+ The hosted side adds what a local loop can't: run history, retries you can inspect, alerts when a
41
+ run *doesn't* happen, and replay. It's opt-in, and only the calls that use it (`sync()`, `run()`,
42
+ the dispatch handler) need credentials.
43
+
44
+ ---
45
+
15
46
  - ✅ **Runs locally with no account** — `npx cronvello dev` starts a real scheduler loop on your
16
47
  machine (timezone/DST, overlap protection, timeouts, retry/backoff). No cloud, no network.
17
48
  - ✅ **Zero runtime dependencies** (Node 20+ `fetch` + WebCrypto).
@@ -27,7 +58,7 @@ npm i @cronvello/sdk
27
58
 
28
59
  ---
29
60
 
30
- ## The idea
61
+ ## The idea (hosted)
31
62
 
32
63
  You declare jobs once, in code. On every deploy you call `sync()`, and the SDK reconciles your
33
64
  registry into Cronvello: **one Cronvello "job" container for your app, one task per registry
@@ -37,7 +68,10 @@ Change a schedule → it's updated. Idempotent, every time.
37
68
 
38
69
  ---
39
70
 
40
- ## Quick start (Express)
71
+ ## Hosted quick start (Express)
72
+
73
+ > Needs a Cronvello account. To stay local, skip to
74
+ > [Local development](#local-development-no-account--cronvello-dev).
41
75
 
42
76
  ```ts
43
77
  // cronvello.ts
@@ -89,7 +123,7 @@ That's it. No dashboard clicks. The jobs in your code are the jobs that run.
89
123
 
90
124
  ---
91
125
 
92
- ## Quick start (Next.js App Router)
126
+ ## Hosted quick start (Next.js App Router)
93
127
 
94
128
  ```ts
95
129
  // app/cronvello/dispatch/route.ts
@@ -221,15 +255,15 @@ your machine that actually fires the handlers when they're due — computing eac
221
255
  its cron expression (timezone- and DST-aware) and enforcing the same production policies the cloud
222
256
  does: **overlap protection, per-run timeout, and retry with backoff**. No cloud, no network.
223
257
 
258
+ Jobs are the only required config. `apiKey`, `appUrl` and `dispatchSecret` belong to the hosted
259
+ side, and nothing local asks for them:
260
+
224
261
  ```ts
225
262
  // cronvello.config.ts
226
263
  import { defineCronvello, every, daily } from "@cronvello/sdk";
227
264
 
228
265
  export const cronvello = defineCronvello({
229
266
  appName: "my-app",
230
- appUrl: "https://my-app.example.com",
231
- apiKey: "crn_local_dev", // any non-empty value — the local engine never calls the API
232
- dispatchSecret: "local-dev-secret-0123456789abcdef",
233
267
  timeZone: "Europe/Berlin",
234
268
  jobs: {
235
269
  heartbeat: { schedule: every("10s"), handler: async () => { console.log("beat"); } },
@@ -295,15 +329,22 @@ npx cronvello dev --dashboard # → http://127.0.0.1:4747 (override
295
329
 
296
330
  Open the printed URL and you get, live:
297
331
 
332
+ - a **summary** across the session: jobs, runs, how many succeeded, how many failed, and what fires
333
+ next,
334
+ - a **timeline** with one lane per job, plotting the runs that already happened next to the fires
335
+ still to come, around a now-line that moves as you watch. A schedule that is wrong is usually
336
+ obvious here before it is obvious anywhere else,
298
337
  - every **job** with its cron expression, timezone, and a **countdown to the next fire**,
299
- - a **run feed** that streams each fire, success, error, timeout, retry, and skip as it happens,
300
- - a **job drawer** with recent runs and the next fire times, plus a **Run now** button that triggers
301
- the handler through the engine and shows the result instantly,
302
- - light + dark, and an honest **disconnected** state if the engine stops.
338
+ - a **run feed** that streams each fire, success, error, timeout, retry, and skip as it happens, with
339
+ a pause that tells you how much you're missing,
340
+ - a **job drawer** with the next fire times and recent runs, each one carrying its **error message**
341
+ or its **returned value**, plus a **Run now** button that triggers the handler through the engine,
342
+ - light + dark, keyboard-reachable rows, and an honest **reconnecting** state if the engine stops.
303
343
 
304
344
  It binds to `127.0.0.1` by default (it's a dev tool, not a public server) and exposes a tiny
305
- read-only JSON API plus an SSE stream — including `GET /api/runs.ndjson` to pipe the run history out
306
- as NDJSON. You can also start it programmatically:
345
+ read-only JSON API plus an SSE stream — `GET /api/state` for everything the page draws, and
346
+ `GET /api/runs.ndjson` to pipe the run history out as NDJSON. You can also start it
347
+ programmatically:
307
348
 
308
349
  ```ts
309
350
  const engine = cronvello.dev({ dashboard: true }); // or { dashboard: { port: 5000 } }
@@ -423,6 +464,10 @@ node -e "console.log(require('@cronvello/sdk').generateDispatchSecret())"
423
464
  Store it as `CRONVELLO_DISPATCH_SECRET` in both your app env and nowhere else — `sync()` registers
424
465
  it with Cronvello as the task's bearer token (encrypted at rest; never returned on read).
425
466
 
467
+ An app defined without a `dispatchSecret` has no HTTP entry point to protect, so `expressHandler()`
468
+ and `nextHandler()` refuse to be mounted at all, and a dispatch that somehow reaches `handle()` is
469
+ rejected. There is no configuration in which an unauthenticated request runs a job.
470
+
426
471
  ---
427
472
 
428
473
  ## Long-running jobs — `async_callback`
@@ -549,6 +594,48 @@ Transient failures (429 / 5xx / network) are retried automatically with backoff.
549
594
 
550
595
  ---
551
596
 
597
+ ## Operator client
598
+
599
+ You do not need this to *use* Cronvello. It is the backend-to-backend surface for the service
600
+ that **provisions apps into** Cronvello — registering them, checking their registration, minting
601
+ new per-app tokens, removing them.
602
+
603
+ It is a separate class on purpose. It runs against the same host as `/v1`, but it takes
604
+ Cronvello's **service key**, which is authorized across every registered app — far broader than
605
+ an account `apiKey`. Two classes with two differently named options means you cannot send the
606
+ wrong credential by accident.
607
+
608
+ ```ts
609
+ import { CronvelloAdminClient } from "@cronvello/sdk";
610
+
611
+ const admin = new CronvelloAdminClient({ serviceKey: process.env.CRONVELLO_SERVICE_KEY! });
612
+
613
+ // Idempotent upsert, keyed on the string appId. Re-run it on every provisioning pass.
614
+ const app = await admin.externalApps.register({
615
+ appId: "node-shop",
616
+ name: "Shop",
617
+ base_url: "https://shop.example.com",
618
+ generateApiKey: true,
619
+ });
620
+ app.generatedApiKey; // plaintext, exactly ONCE, and only for a newly created app
621
+
622
+ const status = await admin.externalApps.status("node-shop");
623
+ // { registered, isActive, isLive, lastSyncedAt, jobCount }
624
+
625
+ // Drift recovery when the current token is lost. Invalidates the old one.
626
+ const { newApiKey } = await admin.externalApps.rotateKey("node-shop");
627
+
628
+ // Takes the NUMERIC app id, not the string appId — an asymmetry in the server contract.
629
+ await admin.externalApps.delete(app.id);
630
+ ```
631
+
632
+ Cross-field rules (`base_url` or `targetUrl`; a key or `generateApiKey`; both OAuth credentials)
633
+ are checked before the request leaves, so a bad call raises `CronvelloConfigError` synchronously
634
+ rather than returning an opaque 400. Everything else — errors, retries, envelope handling —
635
+ behaves exactly as the low-level client above.
636
+
637
+ ---
638
+
552
639
  ## License
553
640
 
554
641
  MIT