@clubedge/create-clubedge-app 0.30.0 → 0.31.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +12 -10
  3. package/dist/deploy-preview.js +2 -0
  4. package/package.json +3 -3
  5. package/template/.env.example +18 -0
  6. package/template/.github/workflows/ci.yml +11 -0
  7. package/template/.github/workflows/preview.yml +13 -9
  8. package/template/README.md +14 -2
  9. package/template/SETUP.md +23 -10
  10. package/template/apps/start/.env.example +18 -0
  11. package/template/apps/start/package.json +3 -1
  12. package/template/apps/start/src/components/jobs/jobs-card.tsx +7 -5
  13. package/template/apps/start/src/env/production.test.ts +8 -0
  14. package/template/apps/start/src/env/production.ts +11 -1
  15. package/template/apps/start/src/routes/api/inngest.ts +16 -0
  16. package/template/apps/start/src/server/jobs.functions.ts +4 -4
  17. package/template/apps/start/src/server/jobs.ts +8 -0
  18. package/template/apps/start/src/server/variants/jobs.bullmq.ts +88 -0
  19. package/template/apps/start/src/server/variants/jobs.inngest.ts +94 -0
  20. package/template/apps/start/src/start.ts +9 -3
  21. package/template/apps/web/package.json +3 -1
  22. package/template/apps/web/src/app/api/inngest/route.ts +15 -0
  23. package/template/apps/web/src/app/dashboard/_components/jobs-card.tsx +8 -6
  24. package/template/apps/web/src/env/production.test.ts +8 -0
  25. package/template/apps/web/src/env/production.ts +11 -1
  26. package/template/apps/web/src/server/jobs.ts +8 -0
  27. package/template/apps/web/src/server/variants/jobs.bullmq.ts +88 -0
  28. package/template/apps/web/src/server/variants/jobs.inngest.ts +97 -0
  29. package/template/clubedge.template.json +63 -0
  30. package/template/package.json +1 -1
  31. package/template/packages/auth/package.json +1 -1
  32. package/template/packages/auth-better-auth/package.json +1 -1
  33. package/template/packages/auth-supabase/package.json +1 -1
  34. package/template/packages/cache/package.json +1 -1
  35. package/template/packages/cache-redis/package.json +1 -1
  36. package/template/packages/core/package.json +1 -1
  37. package/template/packages/db/package.json +1 -1
  38. package/template/packages/email/package.json +1 -1
  39. package/template/packages/email-resend/package.json +1 -1
  40. package/template/packages/email-smtp/package.json +1 -1
  41. package/template/packages/jobs/package.json +3 -2
  42. package/template/packages/jobs/src/conformance.test.ts +5 -0
  43. package/template/packages/jobs/src/conformance.ts +267 -0
  44. package/template/packages/jobs/src/index.test.ts +1 -1
  45. package/template/packages/jobs/src/index.ts +51 -7
  46. package/template/packages/jobs-bullmq/package.json +23 -0
  47. package/template/packages/jobs-bullmq/src/index.test.ts +18 -0
  48. package/template/packages/jobs-bullmq/src/index.ts +279 -0
  49. package/template/packages/jobs-bullmq/tsconfig.json +4 -0
  50. package/template/packages/jobs-inngest/package.json +22 -0
  51. package/template/packages/jobs-inngest/src/index.test.ts +189 -0
  52. package/template/packages/jobs-inngest/src/index.ts +307 -0
  53. package/template/packages/jobs-inngest/tsconfig.json +4 -0
  54. package/template/packages/jobs-pg-boss/package.json +1 -1
  55. package/template/packages/jobs-pg-boss/src/index.test.ts +13 -0
  56. package/template/packages/jobs-pg-boss/src/index.ts +3 -16
  57. package/template/packages/local-db/package.json +1 -1
  58. package/template/packages/observability/package.json +1 -1
  59. package/template/packages/observability-otel/package.json +1 -1
  60. package/template/packages/observability-sentry/package.json +1 -1
  61. package/template/packages/payments/package.json +1 -1
  62. package/template/packages/payments-stripe/package.json +1 -1
  63. package/template/packages/storage/package.json +1 -1
  64. package/template/packages/storage-s3/package.json +1 -1
  65. package/template/packages/storage-supabase/package.json +1 -1
  66. package/template/packages/ui/package.json +1 -1
  67. package/template/pnpm-lock.yaml +2231 -243
  68. package/template.lock.json +18 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.31.0
6
+
7
+ - **Pluggable background jobs**: default to Starter `v0.20.0` (#91). The jobs module now has three providers behind one interface, so job definitions, routes, and the dashboard's jobs card stay the same whichever runs them:
8
+ - `--jobs pg-boss`: jobs in your PostgreSQL, run by a worker in the app server.
9
+ - **`--jobs bullmq`** (new): jobs in Redis through BullMQ 6, run by a worker in the app server. It needs `--cache redis`, so `deploy` creates Railway's Redis with it.
10
+ - **`--jobs inngest`** (new): Inngest calls the app's `/api/inngest` to run each job, so serverless hosts need no cron. `pnpm jobs:dev` starts its Dev Server, and `check:env` requires `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` in production.
11
+ - `@clubedge/jobs/conformance` is one test suite every provider passes: retries, delays, throttling, timeouts, history, and the worker. SETUP.md shows how to add your own provider with it.
12
+ - `runDue`'s `failed` now counts every failed attempt, retries included, for every provider.
13
+ - Switch an existing project with `pnpm clubedge add jobs bullmq` or `pnpm clubedge add jobs inngest`.
14
+ - The showcase's SaaS walkthrough waits for Stripe's billing portal to be on screen before moving on (#90). Stripe renders the portal's text under a spinner and fades it in, which Playwright counted as visible.
15
+
16
+ ## 0.30.1
17
+
18
+ Railway previews, fixed after running them for real on the Starter's own pull requests.
19
+
20
+ - `deploy --preview` sets `PORT` on the Railway preview environment (#87). The preview's domain sends traffic to port 3000, but a production service without `PORT` gave the copied environment none: Railway assigned its own port, and the app answered 502.
21
+ - Default to Starter `v0.19.1` (#88), which fixes the Railway step of `.github/workflows/preview.yml`:
22
+ - It works with a workspace token as well as an account token: it no longer runs `railway link`, which needs an account token, since the Railway CLI targets the project from `RAILWAY_PROJECT_ID`.
23
+ - It sets `PORT=3000` on the pull request's environment, for the same reason as above.
24
+ - The pull request comment lists the Vercel and Railway apps separately when both deploy.
25
+ - Tested on the Starter's pull requests: a Neon branch with the migrations, a Vercel and a Railway preview each answering `/api/ready`, and cleanup of the branch and the Railway environment on merge.
26
+ - If your project deploys previews to Railway, run `pnpm clubedge upgrade` to get the fixed `preview.yml`.
27
+
5
28
  ## 0.30.0
6
29
 
7
30
  - **`--infra neon`** gives a project a hosted Neon database with nothing to install and no account (#84):
package/README.md CHANGED
@@ -129,7 +129,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
129
129
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
130
130
 
131
131
  # Reproduce a specific Starter release or commit
132
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.19.0
132
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.20.0
133
133
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
134
134
 
135
135
  # Test local Starter changes before releasing them
@@ -148,7 +148,7 @@ pnpm clubedge add email resend payments stripe # several modules as
148
148
  pnpm clubedge remove storage payments
149
149
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
150
150
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
151
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.19.0 # or a specific tag, branch, or commit
151
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.20.0 # or a specific tag, branch, or commit
152
152
  ```
153
153
 
154
154
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -259,27 +259,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
259
259
  Each CLI release is tested against one specific Starter release:
260
260
 
261
261
  ```text
262
- create-clubedge-app v0.30.0
262
+ create-clubedge-app v0.31.0
263
263
  |
264
264
  v
265
- Clubedge Starter v0.19.0
266
- commit 1fa6afcefb99f26332f08cd08ac63f6b229cafda
265
+ Clubedge Starter v0.20.0
266
+ commit 166fd38a1b4e8e94e6c411ff26f978ed52ce1249
267
267
  ```
268
268
 
269
269
  The current relationship is:
270
270
 
271
271
  | CLI release | Default Starter release | Meaning |
272
272
  | --- | --- | --- |
273
- | `create-clubedge-app@0.30.0` | `clubedge-starter@v0.19.0` | The CLI's required, tested default |
273
+ | `create-clubedge-app@0.31.0` | `clubedge-starter@v0.20.0` | The CLI's required, tested default |
274
274
 
275
275
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
276
276
 
277
- The `v0.19.0` tag is expected to resolve to commit
278
- `1fa6afcefb99f26332f08cd08ac63f6b229cafda`. The CLI verifies this before
277
+ The `v0.20.0` tag is expected to resolve to commit
278
+ `166fd38a1b4e8e94e6c411ff26f978ed52ce1249`. The CLI verifies this before
279
279
  scaffolding with the default reference and fails rather than silently using a
280
280
  moved tag.
281
281
 
282
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.19.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
282
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.20.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
283
283
 
284
284
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
285
285
 
@@ -334,7 +334,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
334
334
  1. Make and validate changes in `clubedge-starter`.
335
335
  2. Run lint, typecheck, unit tests, browser tests, and production build.
336
336
  3. Update the Starter package versions.
337
- 4. Commit `main`, create a release tag such as `v0.19.0`, and publish the Starter release.
337
+ 4. Commit `main`, create a release tag such as `v0.20.0`, and publish the Starter release.
338
338
 
339
339
  ### CLI maintainers
340
340
 
@@ -374,6 +374,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
374
374
  | 0.28.0 | v0.18.0 | `deploy` to Vercel (experimental) |
375
375
  | 0.29.0 | v0.18.0 | `deploy` to Netlify and Render (experimental) |
376
376
  | 0.30.0 | v0.19.0 | `--infra neon` with a free Neon database, the `neon` command, `deploy --preview` with a Neon branch per preview |
377
+ | 0.30.1 | v0.19.1 | Railway previews work with a workspace token and answer on the port their domain targets |
378
+ | 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
377
379
  | next | | Show the Starter's version of a conflicted file next to yours |
378
380
 
379
381
  ## Community
@@ -192,6 +192,8 @@ async function toRailway(project, cli, environment, branch) {
192
192
  for (const key of ["APP_URL", "NEXT_PUBLIC_APP_URL"]) {
193
193
  await railway(rw, ["variable", "set", key, "--stdin", ...scope, "--skip-deploys"], { input: url });
194
194
  }
195
+ // The domain sends traffic to appPort; without PORT, Railway assigns its own and the app answers 502.
196
+ await railway(rw, ["variable", "set", "PORT", "--stdin", ...scope, "--skip-deploys"], { input: String(appPort) });
195
197
  progress.message("Uploading the project");
196
198
  const started = new Date();
197
199
  const id = parseDeploymentId(await railway(rw, ["up", ...scope, "--detach"]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.19.0",
26
- "starterCommit": "1fa6afcefb99f26332f08cd08ac63f6b229cafda"
25
+ "starterRef": "v0.20.0",
26
+ "starterCommit": "166fd38a1b4e8e94e6c411ff26f978ed52ce1249"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -77,6 +77,24 @@ JOBS_WORKER=
77
77
  CRON_SECRET=
78
78
  # clubedge:end
79
79
 
80
+ # clubedge:if jobs=bullmq
81
+ # Background jobs are stored in Redis (REDIS_URL above) through BullMQ and run by a worker inside
82
+ # the app server. Set JOBS_WORKER=off where no server keeps running (it is off by default on
83
+ # Vercel and Netlify); a scheduler then calls /api/jobs/run with CRON_SECRET (16 or more
84
+ # characters) instead.
85
+ JOBS_WORKER=
86
+ CRON_SECRET=
87
+ # clubedge:end
88
+
89
+ # clubedge:if jobs=inngest
90
+ # Background jobs run by Inngest (https://www.inngest.com), which calls this app's /api/inngest.
91
+ # In development, start its Dev Server with pnpm jobs:dev (http://localhost:8288); INNGEST_DEV=1
92
+ # points the app at it. In production, remove INNGEST_DEV and set the keys of your Inngest app.
93
+ INNGEST_DEV=1
94
+ INNGEST_EVENT_KEY=
95
+ INNGEST_SIGNING_KEY=
96
+ # clubedge:end
97
+
80
98
  # clubedge:if observability=otel
81
99
  # OpenTelemetry: where traces and error logs go over OTLP/HTTP, such as a collector, Grafana
82
100
  # Cloud, or Honeycomb. Empty sends nothing. Headers are key=value pairs separated by commas.
@@ -9,8 +9,19 @@ permissions:
9
9
  jobs:
10
10
  verify:
11
11
  runs-on: ubuntu-24.04
12
+ # clubedge:if jobs=bullmq
13
+ # Redis for the BullMQ provider's tests, which run the jobs conformance suite against it.
14
+ services:
15
+ redis:
16
+ image: redis:7
17
+ ports:
18
+ - 6379:6379
19
+ options: >-
20
+ --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10
21
+ # clubedge:end
12
22
  env:
13
23
  DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
24
+ REDIS_URL: redis://127.0.0.1:6379 # clubedge:only jobs=bullmq
14
25
  steps:
15
26
  - uses: actions/checkout@v7
16
27
  - uses: pnpm/action-setup@v6
@@ -84,8 +84,8 @@ jobs:
84
84
  # clubedge:end
85
85
  # clubedge:if deploy=railway
86
86
  # A Railway environment per pull request, copied from production, with the pull request's
87
- # branch as its database. It needs an account token (RAILWAY_API_TOKEN), since a project
88
- # token can't create environments, and the RAILWAY_PROJECT_ID and RAILWAY_SERVICE variables.
87
+ # branch as its database. It needs an account or workspace token (RAILWAY_API_TOKEN), since a
88
+ # project token can't create environments; the CLI finds the project from RAILWAY_PROJECT_ID.
89
89
  - name: Deploy the preview to Railway
90
90
  id: railway
91
91
  if: env.HAS_RAILWAY == 'true'
@@ -98,9 +98,11 @@ jobs:
98
98
  run: |
99
99
  set -euo pipefail
100
100
  railway() { npx --yes @railway/cli@5 "$@"; }
101
- railway link --project "$RAILWAY_PROJECT_ID" --environment production --service "$RAILWAY_SERVICE"
102
101
  railway environment new "$RAILWAY_ENVIRONMENT" --duplicate production --json || true
103
- for key in DATABASE_URL DATABASE_URL_DIRECT; do
102
+ # The image listens on PORT, and the domain below sends traffic to 3000: set it here
103
+ # rather than rely on production having it, since Railway otherwise assigns its own.
104
+ PORT=3000
105
+ for key in DATABASE_URL DATABASE_URL_DIRECT PORT; do
104
106
  printf '%s' "${!key}" | railway variable set "$key" --stdin \
105
107
  --service "$RAILWAY_SERVICE" --environment "$RAILWAY_ENVIRONMENT" --skip-deploys
106
108
  done
@@ -122,16 +124,19 @@ jobs:
122
124
  GH_TOKEN: ${{ github.token }}
123
125
  PR: ${{ github.event.number }}
124
126
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
125
- # Empty without a deploy step.
126
- URL: ${{ steps.vercel.outputs.url || steps.railway.outputs.url }}
127
+ # Empty without their deploy step.
128
+ VERCEL_URL: ${{ steps.vercel.outputs.url }}
129
+ RAILWAY_URL: ${{ steps.railway.outputs.url }}
127
130
  run: |
128
131
  set -euo pipefail
129
132
  marker="<!-- clubedge-preview -->"
130
133
  body="$marker
131
134
  **Preview** for ${HEAD_SHA:0:7}
132
135
  - Database: Neon branch \`$NEON_BRANCH\`, with this pull request's migrations"
133
- if [ -n "$URL" ]; then body="$body
134
- - App: $URL"; fi
136
+ if [ -n "$VERCEL_URL" ]; then body="$body
137
+ - App on Vercel: $VERCEL_URL"; fi
138
+ if [ -n "$RAILWAY_URL" ]; then body="$body
139
+ - App on Railway: $RAILWAY_URL"; fi
135
140
  id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
136
141
  --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)
137
142
  if [ -n "$id" ]; then
@@ -161,6 +166,5 @@ jobs:
161
166
  RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }}
162
167
  RAILWAY_SERVICE: ${{ vars.RAILWAY_SERVICE }}
163
168
  run: |
164
- npx --yes @railway/cli@5 link --project "$RAILWAY_PROJECT_ID" --environment production --service "$RAILWAY_SERVICE"
165
169
  npx --yes @railway/cli@5 environment delete "$RAILWAY_ENVIRONMENT" --yes
166
170
  # clubedge:end
@@ -68,6 +68,8 @@ Every combination in the CI matrix is generated as a real project and runs its f
68
68
  - Fixed-window rate limiter and cache in process memory, suited to a single server instance. <!-- clubedge:only cache=memory -->
69
69
  - Subscriptions with Stripe Checkout and the customer portal, kept in sync by verified webhooks, behind a provider-neutral payments interface. <!-- clubedge:only payments=stripe -->
70
70
  - Background jobs with retries and schedules, stored in PostgreSQL by pg-boss and run by a worker inside the app server, behind a provider-neutral jobs interface. <!-- clubedge:only jobs=pg-boss -->
71
+ - Background jobs with retries and schedules, stored in Redis by BullMQ and run by a worker inside the app server, behind a provider-neutral jobs interface. <!-- clubedge:only jobs=bullmq -->
72
+ - Background jobs with retries and schedules, run by Inngest, which calls the app for each one, behind a provider-neutral jobs interface. <!-- clubedge:only jobs=inngest -->
71
73
  - Server error reporting and request tracing with OpenTelemetry, to any OTLP collector, behind a provider-neutral observability interface. <!-- clubedge:only observability=otel -->
72
74
  - Server error reporting and request tracing with Sentry, behind a provider-neutral observability interface. <!-- clubedge:only observability=sentry -->
73
75
 
@@ -100,7 +102,7 @@ Pick modules when you create a project, and change them later with `pnpm clubedg
100
102
  | Cache | `redis`, `memory` | `redis` |
101
103
  | Email | `smtp`, `resend`, `none` | `smtp` |
102
104
  | Payments | `stripe`, `none` | `none` |
103
- | Jobs | `pg-boss`, `none` | `none` |
105
+ | Jobs | `pg-boss`, `bullmq`, `inngest`, `none` | `none` |
104
106
  | Observability | `otel`, `sentry`, `none` | `none` |
105
107
  | Local infra | `docker`, `supabase`, `local` (no Docker), `neon`, `none` | `docker` |
106
108
  | Deploy | `docker`, `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
@@ -240,6 +242,14 @@ flowchart TB
240
242
  App -->|jobs interface, worker in the server| Jobs[pg-boss]
241
243
  Jobs -->|jobs table| DB
242
244
  %% clubedge:end
245
+ %% clubedge:if jobs=bullmq
246
+ App -->|jobs interface, worker in the server| Jobs[BullMQ]
247
+ Jobs -->|queues| Redis
248
+ %% clubedge:end
249
+ %% clubedge:if jobs=inngest
250
+ App -->|jobs interface, an event per job| Jobs[Inngest]
251
+ Jobs -->|calls /api/inngest to run each job| App
252
+ %% clubedge:end
243
253
  %% clubedge:if observability=otel
244
254
  App -->|traces and errors| Otel[OTLP collector]
245
255
  %% clubedge:end
@@ -348,8 +358,10 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
348
358
  - `packages/email-resend/`: Resend email provider <!-- clubedge:only email=resend -->
349
359
  - `packages/payments/`: `PaymentProvider` interface, billing logic, and the subscription tables <!-- clubedge:only payments!=none -->
350
360
  - `packages/payments-stripe/`: Stripe payment provider <!-- clubedge:only payments=stripe -->
351
- - `packages/jobs/`: `JobQueue` interface, job definitions, and an in-memory queue for tests <!-- clubedge:only jobs!=none -->
361
+ - `packages/jobs/`: `JobQueue` interface, job definitions, an in-memory queue for tests, and the conformance suite every provider passes <!-- clubedge:only jobs!=none -->
352
362
  - `packages/jobs-pg-boss/`: pg-boss job queue <!-- clubedge:only jobs=pg-boss -->
363
+ - `packages/jobs-bullmq/`: BullMQ job queue in Redis <!-- clubedge:only jobs=bullmq -->
364
+ - `packages/jobs-inngest/`: Inngest jobs, and the handler for `/api/inngest` <!-- clubedge:only jobs=inngest -->
353
365
  - `packages/observability/`: `ObservabilityProvider` interface and an in-memory provider for tests <!-- clubedge:only observability!=none -->
354
366
  - `packages/observability-otel/`: OpenTelemetry provider (OTLP/HTTP traces and error logs) <!-- clubedge:only observability=otel -->
355
367
  - `packages/observability-sentry/`: Sentry provider <!-- clubedge:only observability=sentry -->
package/template/SETUP.md CHANGED
@@ -47,7 +47,8 @@ The variables are:
47
47
  - `SMTP_URL` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=smtp -->
48
48
  - `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
49
49
  - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `STRIPE_PRICE_<PLAN>` (for payments): see [Payments](#payments). <!-- clubedge:only payments=stripe -->
50
- - `JOBS_WORKER` and `CRON_SECRET` (optional): see [Background jobs](#background-jobs). <!-- clubedge:only jobs=pg-boss -->
50
+ - `JOBS_WORKER` and `CRON_SECRET` (optional): see [Background jobs](#background-jobs). <!-- clubedge:only jobs=pg-boss|bullmq -->
51
+ - `INNGEST_DEV`, `INNGEST_EVENT_KEY`, and `INNGEST_SIGNING_KEY`: see [Background jobs](#background-jobs). <!-- clubedge:only jobs=inngest -->
51
52
  - `OTEL_EXPORTER_OTLP_ENDPOINT` (to send traces and errors): see [Observability](#observability). <!-- clubedge:only observability=otel -->
52
53
  - `SENTRY_DSN` (to send errors and traces): see [Observability](#observability). <!-- clubedge:only observability=sentry -->
53
54
  - `STORAGE_*` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=s3 -->
@@ -264,11 +265,13 @@ Turn on the customer portal in the dashboard's billing settings, so "Manage bill
264
265
  Payments go through the provider-neutral `PaymentProvider` interface in `packages/payments`. To use another provider, implement that interface in a new package and return it from `apps/web/src/server/payments.ts`; billing logic, the tables, the routes, and the billing card stay the same.
265
266
 
266
267
  <!-- clubedge:end -->
267
- <!-- clubedge:if jobs=pg-boss -->
268
+ <!-- clubedge:if jobs!=none -->
268
269
 
269
270
  ## Background jobs
270
271
 
271
- Jobs run outside requests, with retries, in a worker that starts inside the app server: `pnpm dev` and the production server both run it, so there is no separate process to deploy. [pg-boss](https://github.com/timgit/pg-boss) stores them in your PostgreSQL, and creates and upgrades its own tables in the `pgboss` schema when the app starts.
272
+ Jobs run outside requests, with retries, in a worker that starts inside the app server: `pnpm dev` and the production server both run it, so there is no separate process to deploy. [pg-boss](https://github.com/timgit/pg-boss) stores them in your PostgreSQL, and creates and upgrades its own tables in the `pgboss` schema when the app starts. <!-- clubedge:only jobs=pg-boss -->
273
+ Jobs run outside requests, with retries, in a worker that starts inside the app server: `pnpm dev` and the production server both run it, so there is no separate process to deploy. [BullMQ](https://docs.bullmq.io) stores them in Redis (`REDIS_URL`, Redis 6.2 or newer), which the cache also uses; keep it on a Redis that doesn't evict keys, since jobs are data. <!-- clubedge:only jobs=bullmq -->
274
+ [Inngest](https://www.inngest.com) runs the jobs: the app sends an event for each one, and Inngest calls the app's `/api/inngest` to run it, with retries and schedules, so nothing runs between requests and serverless hosts need no cron. In development, start Inngest's Dev Server next to `pnpm dev` with `pnpm jobs:dev`, and open its dashboard at <http://localhost:8288> to see every run; `INNGEST_DEV=1` points the app at it. In production, create an app in Inngest, set `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`, remove `INNGEST_DEV`, and sync the deployed `/api/inngest` URL in Inngest's dashboard (Inngest's Vercel and Netlify integrations do it on each deploy). <!-- clubedge:only jobs=inngest -->
272
275
 
273
276
  Define a job in `apps/web/src/jobs` and add it to the list in `apps/web/src/jobs/index.ts`:
274
277
 
@@ -284,12 +287,21 @@ export const welcomeEmail = defineJob<{ userId: string }>({
284
287
  });
285
288
  ```
286
289
 
287
- Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. To send any email in the background, enqueue `sendEmailJob` from `@/jobs` with the message, as the auth emails do. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email!=none -->
288
- Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email=none -->
290
+ Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. To send any email in the background, enqueue `sendEmailJob` from `@/jobs` with the message, as the auth emails do. <!-- clubedge:only email!=none -->
291
+ Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. <!-- clubedge:only email=none -->
289
292
 
290
- Where no server keeps running, as on Vercel and Netlify, there is no worker: `JOBS_WORKER` defaults to `off` there, and you can set it yourself anywhere. Set `CRON_SECRET` to 16 or more random characters and call `GET /api/jobs/run` every minute or so with the header `Authorization: Bearer <CRON_SECRET>`, from Vercel Cron, a Netlify scheduled function, or any scheduler. Each call runs the jobs that are due. Scheduled jobs need a worker.
293
+ A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax, UTC) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own.
291
294
 
292
- Jobs go through the provider-neutral `JobQueue` interface in `packages/jobs`. To use another queue, implement that interface in a new package and return it from `apps/web/src/server/jobs.ts`; job definitions, the routes, and the jobs card stay the same.
295
+ Where no server keeps running, as on Vercel and Netlify, there is no worker: `JOBS_WORKER` defaults to `off` there, and you can set it yourself anywhere. Set `CRON_SECRET` to 16 or more random characters and call `GET /api/jobs/run` every minute or so with the header `Authorization: Bearer <CRON_SECRET>`, from Vercel Cron, a Netlify scheduled function, or any scheduler. Each call runs the jobs that are due. <!-- clubedge:only jobs=pg-boss|bullmq -->
296
+ Inngest keeps the job history: the jobs card reads it from Inngest's API (the Dev Server's locally), and Inngest's dashboard shows each run's attempts, output, and errors. <!-- clubedge:only jobs=inngest -->
297
+
298
+ Jobs go through the provider-neutral `JobQueue` interface in `packages/jobs`, so job definitions, the routes, and the jobs card stay the same whichever provider runs them. Switch providers with `pnpm clubedge add jobs pg-boss`, `bullmq`, or `inngest`. To add another, implement `JobQueue` in a new package, return it from `apps/web/src/server/jobs.ts`, and prove it with the shared conformance suite, as each provider's tests do:
299
+
300
+ ```ts
301
+ import { describeJobQueue } from "@clubedge/jobs/conformance";
302
+
303
+ describeJobQueue("my-queue", (jobs, { worker }) => createMyQueue({ jobs, worker }));
304
+ ```
293
305
 
294
306
  <!-- clubedge:end -->
295
307
  <!-- clubedge:if observability!=none -->
@@ -433,7 +445,7 @@ Vercel only runs the app: the database, Redis, file storage, and email come from
433
445
 
434
446
  Vercel has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deployment's environment. Give Preview deployments their own database, or remove the migration step from `buildCommand` and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
435
447
 
436
- Vercel runs no worker for [background jobs](#background-jobs). Set `CRON_SECRET` and add `"crons": [{ "path": "/api/jobs/run", "schedule": "* * * * *" }]` to `vercel.json`; Vercel Cron sends the secret itself. Cron jobs more frequent than daily need a paid Vercel plan; otherwise call the endpoint from another scheduler. <!-- clubedge:only jobs=pg-boss -->
448
+ Vercel runs no worker for [background jobs](#background-jobs). Set `CRON_SECRET` and add `"crons": [{ "path": "/api/jobs/run", "schedule": "* * * * *" }]` to `vercel.json`; Vercel Cron sends the secret itself. Cron jobs more frequent than daily need a paid Vercel plan; otherwise call the endpoint from another scheduler. <!-- clubedge:only jobs=pg-boss|bullmq -->
437
449
 
438
450
  <!-- clubedge:end -->
439
451
  <!-- clubedge:if deploy=netlify -->
@@ -445,7 +457,7 @@ Netlify only runs the app: the database, Redis, file storage, and email come fro
445
457
 
446
458
  Netlify has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deploy context. Give deploy previews their own database, or remove the migration step from the build command and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
447
459
 
448
- Netlify runs no worker for [background jobs](#background-jobs). Set `JOBS_WORKER=off` and `CRON_SECRET`, then call `/api/jobs/run` every minute from a [scheduled function](https://docs.netlify.com/build/functions/scheduled-functions/) or another scheduler, with the header `Authorization: Bearer <CRON_SECRET>`. <!-- clubedge:only jobs=pg-boss -->
460
+ Netlify runs no worker for [background jobs](#background-jobs). Set `JOBS_WORKER=off` and `CRON_SECRET`, then call `/api/jobs/run` every minute from a [scheduled function](https://docs.netlify.com/build/functions/scheduled-functions/) or another scheduler, with the header `Authorization: Bearer <CRON_SECRET>`. <!-- clubedge:only jobs=pg-boss|bullmq -->
449
461
 
450
462
  <!-- clubedge:end -->
451
463
 
@@ -489,7 +501,8 @@ Production needs these variables:
489
501
  - `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
490
502
  - `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
491
503
  - `REDIS_URL`, when you run more than one instance. <!-- clubedge:only cache=redis -->
492
- - `CRON_SECRET`, where no worker runs and a scheduler calls `/api/jobs/run`. <!-- clubedge:only jobs=pg-boss -->
504
+ - `CRON_SECRET`, where no worker runs and a scheduler calls `/api/jobs/run`. <!-- clubedge:only jobs=pg-boss|bullmq -->
505
+ - `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`, from your Inngest app; `INNGEST_DEV` unset. <!-- clubedge:only jobs=inngest -->
493
506
  - `OTEL_EXPORTER_OTLP_ENDPOINT`, and `OTEL_EXPORTER_OTLP_HEADERS` if your collector needs a key. <!-- clubedge:only observability=otel -->
494
507
  - `SENTRY_DSN`, and `SENTRY_ENVIRONMENT` to tell environments apart. <!-- clubedge:only observability=sentry -->
495
508
  - The `STORAGE_*` variables for your S3-compatible bucket. <!-- clubedge:only storage=s3 -->
@@ -79,6 +79,24 @@ JOBS_WORKER=
79
79
  CRON_SECRET=
80
80
  # clubedge:end
81
81
 
82
+ # clubedge:if jobs=bullmq
83
+ # Background jobs are stored in Redis (REDIS_URL above) through BullMQ and run by a worker inside
84
+ # the app server. Set JOBS_WORKER=off where no server keeps running (it is off by default on
85
+ # Vercel and Netlify); a scheduler then calls /api/jobs/run with CRON_SECRET (16 or more
86
+ # characters) instead.
87
+ JOBS_WORKER=
88
+ CRON_SECRET=
89
+ # clubedge:end
90
+
91
+ # clubedge:if jobs=inngest
92
+ # Background jobs run by Inngest (https://www.inngest.com), which calls this app's /api/inngest.
93
+ # In development, start its Dev Server with pnpm jobs:dev (http://localhost:8288); INNGEST_DEV=1
94
+ # points the app at it. In production, remove INNGEST_DEV and set the keys of your Inngest app.
95
+ INNGEST_DEV=1
96
+ INNGEST_EVENT_KEY=
97
+ INNGEST_SIGNING_KEY=
98
+ # clubedge:end
99
+
82
100
  # clubedge:if observability=otel
83
101
  # OpenTelemetry: where traces and error logs go over OTLP/HTTP, such as a collector, Grafana
84
102
  # Cloud, or Honeycomb. Empty sends nothing. Headers are key=value pairs separated by commas.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,8 @@
24
24
  "@clubedge/email-resend": "workspace:*",
25
25
  "@clubedge/email-smtp": "workspace:*",
26
26
  "@clubedge/jobs": "workspace:*",
27
+ "@clubedge/jobs-bullmq": "workspace:*",
28
+ "@clubedge/jobs-inngest": "workspace:*",
27
29
  "@clubedge/jobs-pg-boss": "workspace:*",
28
30
  "@clubedge/observability": "workspace:*",
29
31
  "@clubedge/observability-otel": "workspace:*",
@@ -11,6 +11,12 @@ import {
11
11
  CardTitle,
12
12
  } from "@clubedge/ui/components/card";
13
13
 
14
+ const runnerDescriptions = {
15
+ worker: "Work runs outside requests in this server's worker, with retries.",
16
+ scheduler: "This server runs no worker: jobs run when /api/jobs/run is called.",
17
+ service: "A jobs service runs each job by calling this app, with retries.",
18
+ };
19
+
14
20
  const timeFormat = new Intl.DateTimeFormat("en", { timeStyle: "medium", timeZone: "UTC" });
15
21
 
16
22
  /** Starts the sample job and lists the current user's recent runs. The form posts to /jobs/sample. */
@@ -22,11 +28,7 @@ export function JobsCard({ status }: { status: JobsStatus }) {
22
28
  <CardHeader className="flex-row items-start justify-between gap-3 border-b px-5 py-5 sm:px-6">
23
29
  <div className="grid gap-1">
24
30
  <CardTitle className="text-base">Background jobs</CardTitle>
25
- <CardDescription>
26
- {status.worker
27
- ? "Work runs outside requests in this server's worker, with retries."
28
- : "This server runs no worker: jobs run when /api/jobs/run is called."}
29
- </CardDescription>
31
+ <CardDescription>{runnerDescriptions[status.runner]}</CardDescription>
30
32
  </div>
31
33
  <span className="grid size-9 place-items-center rounded-lg bg-primary/10 text-primary">
32
34
  <ListChecks aria-hidden="true" className="size-4" />
@@ -55,6 +55,14 @@ describe("production env check", () => {
55
55
  ).toEqual(["DATABASE_URL_DIRECT points at Neon's pooler: use the host without -pooler"]);
56
56
  });
57
57
 
58
+ const hasInngest = requiredVariables({}).some(({ key }) => key === "INNGEST_SIGNING_KEY");
59
+ it.runIf(hasInngest)("refuses Inngest's Dev Server in production", () => {
60
+ expect(checkProductionEnv({ ...complete(), INNGEST_DEV: "1" }).invalid).toEqual([
61
+ "INNGEST_DEV is set: production uses Inngest Cloud's keys, so remove it",
62
+ ]);
63
+ expect(checkProductionEnv({ ...complete(), INNGEST_DEV: "0" }).invalid).toEqual([]);
64
+ });
65
+
58
66
  it("rejects a URL that isn't absolute", () => {
59
67
  const env = { ...complete(), APP_URL: "my-app.vercel.app" };
60
68
  expect(checkProductionEnv(env).invalid).toEqual(["APP_URL is not an absolute URL"]);
@@ -45,10 +45,14 @@ export function requiredVariables(env: Env): Requirement[] {
45
45
  { key: "STRIPE_WEBHOOK_SECRET", use: "payments" },
46
46
  { key: "STRIPE_PRICE_PRO", use: "payments" },
47
47
  // clubedge:end
48
- // clubedge:if jobs=pg-boss
48
+ // clubedge:if jobs=pg-boss|bullmq
49
49
  // No jobs worker runs there: Vercel Cron or another scheduler calls /api/jobs/run with it.
50
50
  { key: "CRON_SECRET", use: "background jobs, run by a cron", serverless: true },
51
51
  // clubedge:end
52
+ // clubedge:if jobs=inngest
53
+ { key: "INNGEST_EVENT_KEY", use: "background jobs (Inngest's event key)" },
54
+ { key: "INNGEST_SIGNING_KEY", use: "background jobs (Inngest's signing key)" },
55
+ // clubedge:end
52
56
  ];
53
57
  const serverless = Boolean(env.VERCEL || env.NETLIFY);
54
58
  return required.filter((requirement) => serverless || !requirement.serverless);
@@ -95,6 +99,12 @@ export function checkProductionEnv(env: Env): ProductionEnvCheck {
95
99
  invalid.push("DATABASE_URL_DIRECT points at Neon's pooler: use the host without -pooler");
96
100
  }
97
101
  }
102
+ // clubedge:if jobs=inngest
103
+ // The Dev Server only runs on a developer's machine: production talks to Inngest Cloud.
104
+ if (env.INNGEST_DEV?.trim() && !/^(0|false)$/i.test(env.INNGEST_DEV.trim())) {
105
+ invalid.push("INNGEST_DEV is set: production uses Inngest Cloud's keys, so remove it");
106
+ }
107
+ // clubedge:end
98
108
  // clubedge:if auth=better-auth
99
109
  const secret = env.BETTER_AUTH_SECRET?.trim();
100
110
  if (secret && secret.length < 32) invalid.push("BETTER_AUTH_SECRET needs at least 32 characters");
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getJobs } from "@/server/jobs";
3
+
4
+ /**
5
+ * Inngest calls this endpoint to find the app's jobs (GET, PUT) and to run each one (POST). Its
6
+ * signing key authenticates the calls; providers that run jobs themselves answer 404.
7
+ */
8
+ async function handle({ request }: { request: Request }): Promise<Response> {
9
+ const jobs = getJobs();
10
+ if (!jobs.handler) return Response.json({ error: "Not found" }, { status: 404 });
11
+ return await jobs.handler(request);
12
+ }
13
+
14
+ export const Route = createFileRoute("/api/inngest")({
15
+ server: { handlers: { GET: handle, POST: handle, PUT: handle } },
16
+ });
@@ -1,19 +1,19 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
2
  import { sampleJob } from "@/jobs";
3
- import { getJobs, isWorkerEnabled } from "./jobs";
3
+ import { getJobs, jobsRunner } from "./jobs";
4
4
  import { sampleJobRequester } from "./jobs-forms";
5
5
  import { reportError } from "@clubedge/core";
6
6
 
7
7
  /** What the dashboard's jobs card shows; serializable, so it can cross to the browser. */
8
8
  export const getJobsStatus = createServerFn({ method: "GET" }).handler(async () => {
9
- const worker = isWorkerEnabled();
9
+ const runner = jobsRunner();
10
10
  try {
11
11
  const jobs = await getJobs().recent(sampleJob, {
12
12
  data: { requestedBy: await sampleJobRequester() },
13
13
  limit: 5,
14
14
  });
15
15
  return {
16
- worker,
16
+ runner,
17
17
  jobs: jobs.map(({ id, name, state, createdAt }) => ({
18
18
  id,
19
19
  name,
@@ -23,7 +23,7 @@ export const getJobsStatus = createServerFn({ method: "GET" }).handler(async ()
23
23
  };
24
24
  } catch (error) {
25
25
  reportError(error, { message: "Background jobs: could not list recent jobs." });
26
- return { worker, jobs: null };
26
+ return { runner, jobs: null };
27
27
  }
28
28
  });
29
29
 
@@ -28,6 +28,14 @@ export function isWorkerEnabled(): boolean {
28
28
  return !process.env.VERCEL && !process.env.AWS_LAMBDA_FUNCTION_NAME;
29
29
  }
30
30
 
31
+ /**
32
+ * How this server's jobs run, for the dashboard: in its own worker, when a scheduler calls
33
+ * /api/jobs/run, or by a jobs service that calls the app.
34
+ */
35
+ export function jobsRunner(): "worker" | "scheduler" | "service" {
36
+ return isWorkerEnabled() ? "worker" : "scheduler";
37
+ }
38
+
31
39
  export function getCronSecret(): string | null {
32
40
  return jobsEnv().CRON_SECRET || null;
33
41
  }