Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ NEXT_PUBLIC_ENV="production"
COLLAB_PORT="4000"
COLLAB_TRUST_PROXY="false"

# ── Inngest (background work) ────────────────────────────────────────────
# Generate each with: openssl rand -hex 32
INNGEST_EVENT_KEY=""
INNGEST_SIGNING_KEY=""
# Run dashboard on the host. The app uses the compose network, so this is host-only.
INNGEST_PORT="8288"

# ── Optional integrations ────────────────────────────────────────────────
# With SKIP_ENV_VALIDATION=true (default here), every var below can stay
# blank — the apps boot fine, but the feature behind a missing credential
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,14 @@ ee/ # enterprise-only code, separately licensed — see ee/README.md
Fastest path — Docker:

```bash
cp .env.example .env # fill in COLLAB_TOKEN_SECRET, BETTER_AUTH_SECRET
cp .env.example .env # fill in COLLAB_TOKEN_SECRET, BETTER_AUTH_SECRET,
# INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY
docker compose up -d --build
```

Spins up Postgres and all three apps in one go. Full walkthrough, including
deploying to a real domain and optional third-party integrations, is in
[docs/docker.md](docs/docker.md).
Spins up Postgres, the Inngest background-work engine, and all three apps in
one go. Full walkthrough, including deploying to a real domain and optional
third-party integrations, is in [docs/docker.md](docs/docker.md).

From source instead — Node ≥22, pnpm 10.33, a Postgres database:

Expand All @@ -80,9 +81,10 @@ pnpm dev
```

`pnpm dev` starts every app together (`apps/app` on :3001, `apps/web` on
:3000, `apps/collab` on :4000). Full walkthrough, including what each
environment variable is for and how to run a single app on its own, is in
[docs/setup.md](docs/setup.md).
:3000, `apps/collab` on :4000); `pnpm dev:inngest` alongside it starts the
Inngest dev server on :8288, which is what runs background work. Full
walkthrough, including what each environment variable is for and how to run a
single app on its own, is in [docs/setup.md](docs/setup.md).

## Contributing

Expand Down
8 changes: 8 additions & 0 deletions apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ MEDIA_BUCKET_NAME="startup-prod-media"
# Generate with: openssl rand -hex 32
CRON_SECRET=""

# Inngest. These defaults are the local `inngest dev` server, which runs
# unsigned and ignores both keys. Against a real server: openssl rand -hex 32
INNGEST_BASE_URL="http://localhost:8288"
INNGEST_EVENT_KEY="local-dev-event-key"
INNGEST_SIGNING_KEY="0000000000000000000000000000000000000000000000000000000000000000"
# Set "false" to talk to a real self-hosted server — the keys must then be its keys.
# INNGEST_DEV="false"

# Short-lived collaboration room token signing (minimum 32 characters).
# The exact same value must be configured for the app and collab services.
# If omitted by the app, BETTER_AUTH_SECRET is used for compatibility.
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"concurrently": "^9.1.2",
"framer-motion": "^11.18.2",
"geist": "^1.3.1",
"inngest": "^4.18.1",
"katex": "^0.16.21",
"lottie-react": "^2.4.1",
"lucide-react": "^0.436.0",
Expand Down
18 changes: 18 additions & 0 deletions apps/app/src/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { serve } from "inngest/next";
import { connection, type NextRequest } from "next/server";

import { inngest } from "@/lib/inngest/client";
import { inngestFunctions } from "@/lib/inngest/functions";

export const maxDuration = 300;

const handler = serve({ client: inngest, functions: inngestFunctions });

// Under `cacheComponents` a `GET` handler is prerendered unless it reaches for
// request-time data, and this one's answer depends on the request's headers.
export async function GET(request: NextRequest, context: unknown) {
await connection();
return handler.GET(request, context);
}

export const { POST, PUT } = handler;
16 changes: 16 additions & 0 deletions apps/app/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ export const env = createEnv({
),
/** HMAC key shared only by the app token issuer and collab verifier. */
COLLAB_TOKEN_SECRET: z.string().min(32),

INNGEST_BASE_URL: z.string().url(),
INNGEST_EVENT_KEY: z.string().min(1),
INNGEST_SIGNING_KEY: z
.string()
.regex(
/^(?:[0-9a-f]{2})+$/i,
"INNGEST_SIGNING_KEY must be bare hex with an even number of characters and no `signkey-` prefix",
),
/** `z.enum`, not `z.coerce.boolean()`, which reads the string `"false"` as true. */
INNGEST_DEV: z.enum(["true", "false"]).optional(),
},

client: {
Expand Down Expand Up @@ -124,6 +135,11 @@ export const env = createEnv({
COLLAB_TOKEN_SECRET:
process.env.COLLAB_TOKEN_SECRET ?? process.env.BETTER_AUTH_SECRET,

INNGEST_BASE_URL: process.env.INNGEST_BASE_URL,
INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY,
INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY,
INNGEST_DEV: process.env.INNGEST_DEV,

// client side variables
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL,
Expand Down
14 changes: 14 additions & 0 deletions apps/app/src/lib/inngest/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Inngest } from "inngest";

import { env } from "@/env";

export const inngest = new Inngest({
id: "scibly-app",
baseUrl: env.INNGEST_BASE_URL,
isDev:
env.INNGEST_DEV === undefined
? env.NODE_ENV === "development"
: env.INNGEST_DEV === "true",
eventKey: env.INNGEST_EVENT_KEY,
signingKey: env.INNGEST_SIGNING_KEY,
});
32 changes: 32 additions & 0 deletions apps/app/src/lib/inngest/functions/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { inngest } from "../client";

export const HEARTBEAT_EVENT = "scibly/heartbeat.requested";

export const heartbeat = inngest.createFunction(
{
id: "heartbeat",
name: "Heartbeat",
retries: 2,
triggers: [{ cron: "*/15 * * * *" }, { event: HEARTBEAT_EVENT }],
},
async ({ event, step }) => {
const beatAt = await step.run("record-beat", () =>
new Date().toISOString(),
);

await step.run("fail-when-asked", () => {
const data: unknown = event.data;
if (
typeof data === "object" &&
data !== null &&
"fail" in data &&
data.fail === true
) {
throw new Error("Heartbeat failed on request");
}
return null;
});

return { beatAt, trigger: event.name };
},
);
15 changes: 15 additions & 0 deletions apps/app/src/lib/inngest/functions/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";

import { inngestFunctions } from ".";

describe("inngestFunctions", () => {
it("is what the serve route registers, so it must not be empty", () => {
expect(inngestFunctions.length).toBeGreaterThan(0);
});

it("has no duplicate ids, which would silently replace one at sync time", () => {
const ids = inngestFunctions.map((fn) => fn.id());

expect(new Set(ids).size).toBe(ids.length);
});
});
3 changes: 3 additions & 0 deletions apps/app/src/lib/inngest/functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { heartbeat } from "./heartbeat";

export const inngestFunctions = [heartbeat];
39 changes: 38 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Self-hosted Scibly: Postgres + all three apps. See docs/docker.md.
# Self-hosted Scibly: Postgres, the Inngest background-work engine, and all
# three apps. See docs/docker.md.
services:
postgres:
# pgvector, not plain postgres: an old migration (embeddings, since
Expand Down Expand Up @@ -34,6 +35,41 @@ services:
postgres:
condition: service_healthy

# A postgres init script only runs on a fresh volume, which would skip every
# already-running install — so this one-shot creates the database on every `up`.
inngest-db:
image: pgvector/pgvector:pg16
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-scibly}
entrypoint:
- sh
- -c
- >
psql -h postgres -U ${POSTGRES_USER:-scibly} -d ${POSTGRES_DB:-scibly}
-tc "SELECT 1 FROM pg_database WHERE datname = 'inngest'" | grep -q 1
|| psql -h postgres -U ${POSTGRES_USER:-scibly} -d ${POSTGRES_DB:-scibly}
-c "CREATE DATABASE inngest"
depends_on:
postgres:
condition: service_healthy

# See docs/adr/0004-inngest-self-hosted-orchestration.md.
inngest:
# Matches the inngest-cli devDependency, so dev and production run one version.
image: inngest/inngest:v1.44.0
restart: unless-stopped
# `-u` is polled, not called once, so neither service has to wait for the other.
command: inngest start -u http://app:3001/api/inngest
environment:
INNGEST_POSTGRES_URI: postgresql://${POSTGRES_USER:-scibly}:${POSTGRES_PASSWORD:-scibly}@postgres:5432/inngest
INNGEST_EVENT_KEY: ${INNGEST_EVENT_KEY:?INNGEST_EVENT_KEY is required - see .env.example}
INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required - see .env.example}
ports:
- "${INNGEST_PORT:-8288}:8288"
depends_on:
inngest-db:
condition: service_completed_successfully

collab:
build:
context: .
Expand Down Expand Up @@ -63,6 +99,7 @@ services:
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-scibly}:${POSTGRES_PASSWORD:-scibly}@postgres:5432/${POSTGRES_DB:-scibly}
INNGEST_BASE_URL: http://inngest:8288
ports:
- "3001:3001"
depends_on:
Expand Down
40 changes: 40 additions & 0 deletions docs/adr/0004-inngest-self-hosted-orchestration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Background work runs on a self-hosted Inngest

Anything that outlives a request, so scheduled syncs, long generations, and
anything that has to retry, is an Inngest function. Functions live in
`apps/app/src/lib/inngest/`, get listed in `functions/index.ts`, and are served
from one route at `/api/inngest`. The engine driving them is the
`inngest/inngest` container in `docker-compose.yml`, on its own database on the
Postgres already there. Not Inngest Cloud, not a hosted queue.

This replaces hand-rolled cron chaining, where a route takes a lease row, runs
one step, then calls itself through `after()` before the platform timeout.
`apps/app/src/app/api/cron/sync-integrations/route.ts` is the last one. It stays
until integration sync moves over.

## Why

Chaining is a scheduler, a queue, a retry policy, and a run log written by hand,
and only the parts we noticed we needed. A step that dies mid-way leaves a lease
to expire and no record of what happened.

Self-hosted rather than Inngest Cloud because Scibly ships as a container people
run themselves. An engine that phones a vendor means either a second, weaker
code path for on-prem or an Inngest account as a condition of installing. Vercel
Queues and Workflows lose on the same point, since they exist only inside Vercel.

## Consequences

- `INNGEST_BASE_URL`, `INNGEST_EVENT_KEY`, and `INNGEST_SIGNING_KEY` are all
required with no defaults, and the two keys have to match the ones the server
started with. A deployment with no server to point at fails to boot rather
than silently dropping background work. `INNGEST_DEV` switches signing
explicitly instead of inferring it from whether a URL is set.
- Inngest owns a separate `inngest` database. Backups that dump only `scibly`
hold no run history.
- The server calls the app over HTTP, so the app is a service the engine reaches
rather than a worker that dials out. Any topology has to allow that.
- `maxDuration` on `/api/inngest` bounds one step, not a run. A model call that
might outlast it belongs in `step.ai.infer`, which parks the request on the
server instead.
- Development needs `pnpm dev:inngest` running alongside `pnpm dev`.
12 changes: 10 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Architecture

Three deployables, one Postgres database, a layer of shared packages —
pnpm + Turborepo monorepo.
Three deployables, one Postgres database, a background-work engine, a layer
of shared packages — pnpm + Turborepo monorepo.

```mermaid
flowchart TB
App["apps/app\nthe product (:3001)"]
Web["apps/web\nmarketing site (:3000)"]
Collab["apps/collab\nrealtime editor sync (:4000)"]
Inngest["inngest\nbackground work (:8288)"]
Shared["packages/\ndb, auth, api, ui, ..."]
EE["ee/\nStripe billing (separately licensed)"]
DB[(PostgreSQL)]
Expand All @@ -18,6 +19,8 @@ flowchart TB
Shared --> DB
Shared -. plugs in .-> EE
App -. "Yjs over WebSocket" .-> Collab
Inngest -- "invokes /api/inngest" --> App
Inngest --> DB
```

- **`apps/app`** — the product: notebook (AI drafts a course from an
Expand All @@ -28,6 +31,11 @@ flowchart TB
auth and billing with `apps/app`.
- **`apps/collab`** — a standalone Hocuspocus/Yjs server for realtime course
editing, deployed separately from the two Next.js apps.
- **`inngest`** — the self-hosted background-work engine: schedules, retries,
and records every function that outlives a request. It isn't code in this
repo, it's a container that calls `apps/app` back over HTTP; the functions
live in `apps/app/src/lib/inngest/`. See
[ADR 0004](adr/0004-inngest-self-hosted-orchestration.md).
- **`packages/`** — shared code: `db` (Prisma/Postgres schema, the source of
truth), `auth` (better-auth), `api` (tRPC + entitlement), plus `ui`,
`i18n`, `email`, `observability`, and lower-level helpers.
Expand Down
Loading
Loading