Entry point
apps/core-api/src/server.ts is the only entry point. pnpm dev:api runs it through nodemon and tsx (apps/core-api/nodemon.json), and production runs the compiled dist/server.js.
The file does things in a fixed order:
1
Load and validate env
import './load-env.js' is the first import. It calls loadEnv(), which loads .env through dotenv and validates process.env against the Zod schema in config.ts. A missing or invalid value prints the issues to stderr and exits with code 78. See Configuration.2
Init telemetry and process handlers
initTelemetry('core-api') is called before anything else is imported. It is currently a no-op placeholder in @perform/observability. Then unhandledRejection is logged and uncaughtException is logged and exits with code 1.3
Build the dependencies
main() creates the Prisma client, the queue factory (which owns the one shared Redis connection), the SmartSend client and the Redis nonce store, then assembles the AppContext.4
Create the app and listen
createApp({ context, readinessChecks }) returns the Express app. The server listens on env.PORT. keepAliveTimeout is set to 65 seconds and headersTimeout to 66 seconds so a proxy with a 60 second idle timeout never reuses a socket the API has already closed.5
Start workers
Eleven workers and schedulers start in the same process, then two producer hooks are installed:
installPushReceipts(context) and installBillingSeatCache(context). See Queues and jobs.AppContext
apps/core-api/src/context.ts defines the container. There are no global singletons in core-api code. Everything is passed down from this object.
@repo/* packages do not receive the context. They were ported from the web app and read process.env directly and use their own Prisma singleton (db from @repo/database). Both clients point at the same database. Keep that in mind when a change needs a transaction that spans a domain write and an auth write: the two clients do not share one.How a router receives the context
buildV1Router(ctx, webLimiter) in modules/index.ts calls each module’s router factory with the context. The factory wires repository, service and controller by hand:
productsRouter builds a subscriptions service, automationApiRouter builds clients, forms, templates and subscriptions services. Nothing is registered in a container.
Two pieces are shared across routers instead of being rebuilt:
billingFor(ctx)inmodules/billing/billing.wiring.tsmemoizes one billing set per context in aWeakMap, so every router that enforces plan limits shares the same resolver and seat cache.installPushReceiptSchedulerinlib/push-receipts.tsstores a module level scheduler function. Push call sites schedule a receipt check without knowing a queue exists. In tests and scripts nothing is installed and scheduling is a no-op.
Readiness
buildReadinessChecks({ prisma, redis }) in health-checks.ts returns two checks:
/readyz answers 200 with { "ok": true } when every required check passes. A failing optional check is listed under degraded and does not fail readiness. A failing required check answers 503 with failed. Redis is optional on purpose, which is why durable state for delayed work lives in Postgres and not only in delayed jobs.
Shutdown
SIGTERM and SIGINT run the same sequence: close every worker, close the queue connection, disconnect Prisma, then close the HTTP server. A 25 second unref’d timer forces process.exit(0) if something hangs.
SIGUSR2 (sent by nodemon and by a pm2 reload) runs the same closes and then re-raises SIGUSR2 so the supervisor restarts the process. Every close() swallows its own error so one failing worker cannot block the rest.
Process model in production
ecosystem.config.cjs runs one instance in pm2 fork mode with a memory ceiling of 1024M. The Dockerfile builds the workspace and runs node apps/core-api/dist/server.js.
One instance matters for a few in-memory caches that are per process:
resolvedStudiosandprovisionedOwnerCoachesinmiddleware/web-context.ts.- The trainee access cache and live studio cache in
middleware/trainee-auth.ts(30 second TTL). - The trainee time zone cache in
lib/trainee-timezone.ts(12 hours). express-rate-limitcounters, which use the default in-memory store.