- TypeScript 56.7%
- Svelte 41.7%
- CSS 0.9%
- Dockerfile 0.6%
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/. |
||
|---|---|---|
| .agents/skills/shadcn-svelte | ||
| .claude | ||
| .forgejo/workflows | ||
| .vscode | ||
| apps/web | ||
| docs | ||
| examples/http-queue-demo | ||
| packages | ||
| .dockerignore | ||
| .env.deploy.example | ||
| .env.example | ||
| .gitignore | ||
| .mcp.json | ||
| .npmrc | ||
| AGENTS.md | ||
| compose.deploy.yaml | ||
| compose.yaml | ||
| Dockerfile | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| skills-lock.json | ||
| tsconfig.base.json | ||
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/sdkverifies 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
queuerow becomesboss.createQueue/boss.updateQueue.policycannot change after creation, so a mismatch is warned about rather than silently applied. - A
cronjobrow becomesboss.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
kindcolumn. - 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-demois 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 scenariosruns the whole tour. - packages/sdk/README.md — the
ClientandReceiverAPI, 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_URLandSECRETlive in the root.env.apps/web/vite.config.tscopies loaded env ontoprocess.env(so the framework-agnostic packages see it) andpackages/db/drizzle.config.tsloads the same file. Real environment variables always win over.env.db:pushis the development flow; usedb:generate+db:migratewhen 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.