No description
  • TypeScript 56.7%
  • Svelte 41.7%
  • CSS 0.9%
  • Dockerfile 0.6%
Find a file
Joshue Abance 8c84da0372
All checks were successful
CI & Deploy / ci (push) Successful in 2m5s
CI & Deploy / deployment (push) Successful in 56s
fix(web): bundle zod so the built server starts without node_modules
zod sat in apps/web's dependencies, which adapter-node externalises, so the
runtime image (build/ only, no node_modules) failed at start with
ERR_MODULE_NOT_FOUND. Move it to devDependencies like every other package the
server needs, so it is bundled into build/.
2026-10-18 07:44:11 +08:00
.agents/skills/shadcn-svelte chore: initial commit 2026-10-02 00:53:58 +08:00
.claude chore: initial commit 2026-10-02 00:53:58 +08:00
.forgejo/workflows ci: forgejo workflow for check, test, build and deploy 2026-10-18 07:29:51 +08:00
.vscode chore: initial commit 2026-10-02 00:53:58 +08:00
apps/web fix(web): bundle zod so the built server starts without node_modules 2026-10-18 07:44:11 +08:00
docs feat: URL groups, one publish delivered to every endpoint 2026-10-17 08:03:57 +08:00
examples/http-queue-demo feat(web): require a SECRET, and key sessions and the publish token to it 2026-10-05 06:18:03 +08:00
packages fix(db): migrate loads the root .env, not packages/.env 2026-10-18 07:33:44 +08:00
.dockerignore fix(deploy): stop .dockerignore from excluding the markdown the build inlines 2026-10-05 06:32:36 +08:00
.env.deploy.example fix(web): throttle sign-in, and make an unknown email cost a full hash 2026-10-17 07:27:46 +08:00
.env.example chore(dev): name the local database chronq 2026-10-17 11:02:25 +08:00
.gitignore feat(sdk): make the package publishable to npm 2026-10-04 12:01:12 +08:00
.mcp.json chore: initial commit 2026-10-02 00:53:58 +08:00
.npmrc chore: initial commit 2026-10-02 00:53:58 +08:00
AGENTS.md chore: initial commit 2026-10-02 00:53:58 +08:00
compose.deploy.yaml fix(web): throttle sign-in, and make an unknown email cost a full hash 2026-10-17 07:27:46 +08:00
compose.yaml chore(dev): name the local database chronq 2026-10-17 11:02:25 +08:00
Dockerfile feat(web): require a SECRET, and key sessions and the publish token to it 2026-10-05 06:18:03 +08:00
LICENSE chore: license under MIT 2026-10-05 00:52:55 +08:00
package.json chore: initial commit 2026-10-02 00:53:58 +08:00
pnpm-lock.yaml fix(web): bundle zod so the built server starts without node_modules 2026-10-18 07:44:11 +08:00
pnpm-workspace.yaml feat(examples): add a Hono receiver for trying the HTTP queue 2026-10-02 00:53:58 +08:00
README.md fix(web): address cronjobs by name, not by their serial 2026-10-06 00:10:55 +08:00
skills-lock.json chore: initial commit 2026-10-02 00:53:58 +08:00
tsconfig.base.json chore: initial commit 2026-10-02 00:53:58 +08:00

chronq

chronq is a self-hosted HTTP queue. You publish a message to a destination URL; chronq delivers it — signed, retried, and recorded — and tells you whether it worked.

If you have used Upstash QStash, the shape will be familiar. chronq is the same idea as software you run yourself, beside the services it calls, with a web UI for your queues and cronjobs instead of a dashboard someone else operates.

What that gives you:

  • HTTP delivery, not just job storage. Publish a URL, a body and options; chronq makes the request, with timeouts, retries and exponential backoff.
  • Signed deliveries. Every request carries an HS256 JWT in chronq-signature. @chronq/sdk verifies it in one call, so a destination can refuse anything that did not come from your instance.
  • Scheduling, not only queues. Delay a message, hold it until a timestamp, or attach a cronjob written as a cron expression or an RFC 5545 RRULE.
  • A record of what happened. Every attempt is kept with its status, duration, attempt count and the destination's response body and headers.
  • Very little to operate. Postgres and one Node process. No broker, no Redis, no third-party account.

The rest of this file is the guide: how to run it, what the moving parts are, and where to read more.

Project structure

apps/
  web/                  SvelteKit app: admin UI, oRPC endpoint and client, auth,
                        publish API, delivery worker
packages/
  api/                  oRPC router, owns the API contract types
  db/                   Drizzle schemas and client
  jobs/                 pg-boss instance, publish, delivery worker, sync
  sdk/                  Client (publish) and Receiver (verify signatures)
examples/
  http-queue-demo/      Hono receiver for trying the HTTP queue by hand
docs/
  http-queue.md         The HTTP queue: every option, retry and signature rule
  http-queue-demo.md    The same thing by hand, with curl and expected output
  deployment.md         Running the app image on a server
compose.yaml            Postgres for local development
compose.deploy.yaml     Postgres + migration + app, for a server
Dockerfile              Standalone Node build of apps/web (no node_modules)
.env                    Single env file, shared by the app and all packages
components.json         shadcn-svelte config (components land in
                        apps/web/src/lib/components/ui)

Every directory under packages/ is a workspace package. They ship TypeScript source rather than build output — their exports point at src/index.ts and Vite compiles them — so there is no build step to keep in sync between them.

Getting started

pnpm install
pnpm db:start     # Postgres in Docker (waits for healthy)
pnpm db:push      # sync packages/db/src/schema.ts to the database
pnpm dev          # http://localhost:5173
Script What it does
pnpm dev Run the SvelteKit dev server
pnpm build Production build of every workspace package
pnpm check svelte-check across the workspace
pnpm db:start Start Postgres (Docker), stop with db:stop
pnpm db:push Push the schema straight to the database
pnpm db:generate Write a SQL migration to packages/db/drizzle
pnpm db:migrate Apply committed migrations
pnpm db:studio Drizzle Studio
pnpm test Node test runner over the workspace
pnpm jobs:sync Push the queue/cronjob tables into pg-boss

Run any script against one package with a filter, e.g. pnpm --filter @chronq/db db:push.

pnpm dev does two jobs at once: it serves the admin UI and runs the worker that delivers jobs. pnpm --filter @chronq/jobs worker runs that worker on its own, for when you want to split it out of the web process.

First run

On the first request the app checks whether an admin row exists. Until it does, every page redirects to /setup, where you create the single admin account. Once it exists /setup returns 404 and /login takes over. There is no init flag to fall out of sync: the admin row is the flag.

GET /rpc is never redirected — an API client gets 401 instead of a login page.

Queues and cronjobs

pg-boss owns its own pgboss schema — queue, job, schedule, and the rest are created and migrated by boss.start(). Drizzle is scoped to public and never touches it, so no pg-boss internal table is declared twice or pinned to a version.

The queue and cronjob tables in public are the app's editable definitions. syncQueues() / syncCronjobs() in @chronq/jobs push them across:

  • A queue row becomes boss.createQueue / boss.updateQueue. policy cannot change after creation, so a mismatch is warned about rather than silently applied.
  • A cronjob row becomes boss.schedule(queue.name, expression, { cronjobId }, { key: cronjob.name }). Only the id travels in the payload, so editing the url, headers or body takes effect on the next run without re-syncing.
  • Cron expressions and RFC 5545 RRULEs both work; pg-boss records which one a schedule is in its own kind column.
  • Deleting a queue does not delete the pg-boss queue implicitly — that would discard its jobs — so it is done deliberately, and the confirmation says how many cronjobs go with it.
  • Deleting a cronjob removes the queue that was created for it (source: 'auto'), unless a hand-made queue, a dead letter reference, or another cronjob still needs it. The run history lives in that queue's partition, so it goes too — which is why an auto queue is only removed once nothing else uses it.
  • Job history is read back from pg-boss rather than mirrored into public.

A cronjob does not need a queue created up front: choosing Create one for this cronjob makes one named after the cronjob. Auto-created queues are left out of the queues index because they exist for a single cronjob, but they stay reachable from the cronjob that owns them, where the retry settings and event log live.

pnpm jobs:sync

The HTTP queue

Publish a message to a destination URL; chronq delivers it signed, with retries, and reports the outcome to a callback.

# into the default queue
curl -X POST "http://localhost:5173/v2/publish/https://example.com/hook" \
  -H "Authorization: Bearer $CHRONQ_PUBLISH_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"orderId":42}'

# into a named queue
curl -X POST "http://localhost:5173/v2/publish/webhooks/https://example.com/hook" \
  -H "Authorization: Bearer $CHRONQ_PUBLISH_TOKEN" -d '{"orderId":42}'

The publish token and signing keys are on the Account page after sign-in. Deliveries carry a chronq-signature header; verify it before acting on the body.

Everything else is in the two guides below, and the SDK's own README:

  • docs/http-queue.md — the full reference: every option header, retry and callback semantics, deduplication, signature claims, running a receiver locally, key rotation, troubleshooting, and the deliberate deviations from QStash.
  • docs/http-queue-demo.md — the same thing by hand. examples/http-queue-demo is a runnable Hono receiver with a route per behaviour, published through a queue you create in the dashboard; the guide walks through each one with curl and the output to expect. pnpm --filter @chronq/http-queue-demo scenarios runs the whole tour.
  • packages/sdk/README.md — the Client and Receiver API, for publishing and verifying from TypeScript instead of curl.

Admin UI

Everything is server-rendered with SvelteKit form actions, so the whole admin works without JavaScript; shadcn components are used throughout.

The signed-in app lives under /app; / redirects there. Everything outside that prefix is either public (/setup, /login, /docs) or an API (/rpc, /v2/publish/..., POST /logout).

Route What it does
/setup First-run: create the solo admin. Ceases to exist afterwards.
/login Sign in
/app Dashboard: queue depth, job states, recent activity, cronjobs
/app/queues List, create, delete
/app/queues/[id] Settings, live depth, and the queue's event log
/app/cronjobs List, create, delete
/app/cronjobs/[name] Settings, next occurrences, run now, and just that cronjob's runs
/app/account Email, password, publish token, signing keys
/docs This guide and the documents above, rendered as a sub-site

The Events tab on a queue or cronjob lists every run with its HTTP status, duration, attempt count and the destination's response body and headers.

Deployment

compose.deploy.yaml runs Postgres, a one-shot migration and the app. The app image is a standalone node build with no node_modules — deliberately, because Node refuses to strip types for files under node_modules and packages/* ship TypeScript source.

cp .env.deploy.example .env.deploy     # set POSTGRES_PASSWORD (openssl rand -hex 32), ORIGIN, SECRET
docker compose --env-file .env.deploy -f compose.deploy.yaml up -d --build

docs/deployment.md covers the whole deploy: the stack, nginx in front of it, migrations, and the ORIGIN and BODY_SIZE_LIMIT traps that fail quietly rather than at boot.

Development

The API contract

packages/api owns the contract. It exports router (runtime) and AppRouter (type). apps/web reads the type, so the client is typed without ever importing server code into the browser.

The oRPC handler is mounted at /rpc in apps/web/src/routes/rpc/[...rest]/+server.ts; the typed client is apps/web/src/lib/orpc.ts. Add a procedure in packages/api/src/index.ts and it appears on the client immediately.

GET is deliberately not exported — oRPC excludes it by default because a plain link or image tag can trigger it with cookies attached (CSRF).

Auth

One admin account, enforced by the database: admin.id is pinned to 1 by a check constraint, so a second row is impossible and two racing setup requests resolve to one winner (303) and one 409. Passwords use Node's built-in scrypt at N=2^16 (~77 ms, 64 MiB per hash) with the parameters stored in the hash so the cost can be raised later.

Sessions store an HMAC of the cookie token keyed with SECRET, never the token, so a database leak does not hand out live sessions. The cookie is httpOnly + SameSite=Lax, and Secure is set from the request protocol so self-hosted plain HTTP works. ?next= is restricted to same-site paths without control characters.

SECRET is the one application secret and is required — 32 characters minimum, no default, and no fallback. It also seals the publish token in the database (admin.api_token), which has to stay readable because the account page displays it. $lib/server/secret.ts derives a separate subkey per use, so the session pepper and the storage key are not the same value.

Environment and migrations

  • DATABASE_URL and SECRET live in the root .env. apps/web/vite.config.ts copies loaded env onto process.env (so the framework-agnostic packages see it) and packages/db/drizzle.config.ts loads the same file. Real environment variables always win over .env.
  • db:push is the development flow; use db:generate + db:migrate when you want reviewable SQL migrations. Don't mix both against one database.

Client bundle size

Client components import vocabulary (QUEUE_POLICIES, JOB_STATES) from the import-free @chronq/db/constants and @chronq/jobs/constants subpaths. Import them from the package roots instead and Vite bundles the whole Drizzle/postgres/ pg-boss graph into the browser: it cost 850 kB before the split.

Reading job history

Event rows are read from pg-boss's own job table. findJobs() takes no state filter and no limit, so it cannot back a paginated log view; pg-boss documents direct access to its tables for exactly this reason. packages/jobs/src/history.ts is read-only against it.

AI tooling

sv add ai-tools output lives at the repo root: AGENTS.md (Svelte MCP usage instructions + skills), .mcp.json (local @sveltejs/mcp over stdio), .claude/ (skills and the svelte-file-editor sub-agent) and .vscode/extensions.json.

The shadcn-svelte skill is also installed at the repo root, from npx skills add huntabyte/shadcn-svelte. It lands in .agents/skills/, which pi discovers by walking up from the working directory — installing it inside apps/web would hide it, even though components.json lives there.