Skip to content

Connectors

A connector is the framework’s adapter for a subsystem that has a lifecycle — connect at boot, restart on config change, disconnect cleanly at exit. The database, the HTTP server, the cache, storage, the mailer, the logger, the message broker (herald), the socket server, notifications, the authorization layer (access), and the AI toolkit (ai) are each owned by one. The framework starts them in priority order around your app code, and tears them down in reverse order on shutdown.

This is the catalog page: it names every built-in, tells you the one rule that governs whether a connector does anything (the config file), and lists the priorities and phases at a glance. For the boot sequence in detail — and for writing your own connector — see Bootstrap and connectors.

Two facts do most of the work:

  1. All eleven built-in connectors are ALWAYS registered. The ConnectorsManager constructor instantiates every one of them, unconditionally. Your config files do not add or remove connectors.
  2. The config file is the declarative on/off switch. Each connector’s start() calls config.get("<name>") and returns immediately if that config is absent. Present config → the subsystem activates. Missing config → the connector is registered but no-ops. You pay only for the subsystems you actually configure.
flowchart TD
    ctor["ConnectorsManager constructor<br/><i>registers ALL 11 connectors, sorts by priority</i>"]
    start["connector.start()"]
    check{"config.get('name')<br/>present?"}
    activate["activate subsystem<br/><i>connect, set this.active = true</i>"]
    noop["return — no-op<br/><i>connector stays inert</i>"]

    ctor --> start
    start --> check
    check -->|yes| activate
    check -->|no| noop

So you never “attach” a connector to enable a feature. You drop the matching src/config/<name>.ts file (database, cache, socket, access, …), and the always-present connector picks it up on the next boot.

Connectors are always registered — config activates the subsystem

Section titled “Connectors are always registered — config activates the subsystem”

This is the point people get wrong, so it’s worth being blunt: there is no config flag that detaches a connector. The constructor of ConnectorsManager runs this.register(new LoggerConnector()), new DatabaseConnector(), and so on through all ten — every time, in every app.

What the config file controls is whether the connector does anything when its start() runs:

src/connectors/database-connector.ts (shape)
public async start(): Promise<void> {
const databaseConfig = config.get("database");
if (!databaseConfig) {
return; // no config file → connector stays inert
}
const source = await connectToDatabase(databaseConfig);
container.set("database.source", source);
this.active = true;
}

Every built-in follows this pattern. The storage connector (storage) skips the explicit config.get check and instead relies on its driver’s own no-op when no disks are configured, but the effect is the same: no configuration, no side effect.

All ten connectors, in priority order (lower starts first). The public name is the value the connector registers under (used in logs and the registry) — note that the priority constant for herald is COMMUNICATOR, but the connector’s runtime name is "herald".

PriorityPublic nameClassPhaseWatchesWhat it wires
0loggerLoggerConnectorEarlysrc/config/log.tssetLogConfigurations(...); flushes synchronously on shutdown so final lines hit disk
1mailerMailerConnectorEarlysrc/config/mail.ts, .envsetMailConfigurations(...); closeAllMailers() on shutdown to release SMTP pools
2databaseDatabaseConnectorEarlysrc/config/database.tsconnectToDatabase(...) (Cascade); stores the DataSource in the container at "database.source"
3heraldHeraldConnectorEarlysrc/config/herald.tslazy-imports @warlock.js/herald, connectToBroker(...) (message broker / queues)
4cacheCacheConnectorEarlysrc/config/cache.tscache.setCacheConfigurations(...) then await cache.init()
5httpHttpConnectorLatesrc/config/http.tsbuilds Fastify in boot(), scans the router in start(), stores the instance at "http.server"
6storageStorageConnectorEarlysrc/config/storage.tsloadS3() (lazy AWS SDK) then await storage.init()
7socketSocketConnectorLatesrc/config/socket.tslazy-imports socket.io, builds the Socket.IO Server in boot(), stores it at "socket"
8notificationsNotificationsConnectorEarlysrc/config/notifications.tslazy-imports @warlock.js/notifications, setNotificationConfig(...)
9accessAccessConnectorEarlysrc/config/access.tslazy-imports @warlock.js/access, setAccessConfig(...) — validates a resolver is present so a misconfigured authorization layer fails at STARTUP
10aiAiConnectorEarlysrc/config/ai.tslazy-imports @warlock.js/ai, ai.config(...) from the ejected config/ai.ts; satellite side-effect imports at the top of that file register their surface first
5.5webWebConnector (@warlock.js/web)Lateregisters page routes (dev: discovered and loaded through Vite; production: read from the build’s page manifest); mounts Vite in middleware mode in dev

web is not one of the framework’s built-ins above — it’s registered by warlock add web, which wires it into warlock.config.ts > connectors. Its priority (5.5) sits deliberately between http (5) and storage (6): its boot() needs the Fastify instance http.boot() just published in the container, and reverse-priority teardown closes Vite before the HTTP server does.

A few notes worth calling out:

  • herald — the priority constant is ConnectorPriority.COMMUNICATOR (= 3) because herald is the project’s outbound channel (broker, queues, event fan-out), but the connector’s public name in the registry is "herald". The package is dynamically imported, so core carries no hard dependency on it.
  • http is the only Late connector that other connectors read from. It builds the Fastify instance in boot() and stores it at "http.server" so the socket connector (which boots after it within the Late phase) can share the underlying Node HTTP server.
  • socket lazy-imports socket.io; if a socket config is present but the optional socket.io peer isn’t installed, boot() throws with install instructions.
  • notifications and access follow the same lazy-import-on-config pattern as herald. access additionally validates its resolver at startup — a broken authorization config surfaces during boot rather than on the first protected request.
  • ai (priority 10, the newest built-in) lazy-imports @warlock.js/ai only when src/config/ai.ts is present and applies it via ai.config(...). The config file is ejected by warlock add ai and carries an auto-managed // >>> warlock:ai-packages import block at the top, where the satellite features (ai-tools, ai-panoptic, ai-workspace) link their side-effect imports — so those packages augment the ai object (e.g. ai.tools, ai.workspace, panoptic’s wiring) before this connector applies the config. See Adding features with warlock add.

What each connector adapts (the driver layer)

Section titled “What each connector adapts (the driver layer)”

Each connector is a thin lifecycle adapter — it reads its config and hands it to one subsystem’s setup function. The actual DRIVER selection (which cache store, which storage disk, which mail transport, which database/broker driver) happens INSIDE that subsystem via its config, not in the connector. The herald/notifications/access connectors additionally lazy-import their package, which is what keeps those packages optional peers.

ConnectorAdapts (package / runtime)Driver layer (selected via config)Driver docs
logger@warlock.js/loggerlog channelsLogger topic / Logging
mailercore mail layerSMTP / AWS SES transportsMail
database@warlock.js/cascadeDataSource driver (mongodb/postgres/mysql)Cascade topic
herald@warlock.js/heraldbroker driver (heraldConfig.driver)Herald topic
cache@warlock.js/cachecache driver (memory/redis/file/pg/…)Cache topic
httpFastify— (plugins: cors/cookies/upload/rate-limit)Request lifecycle
storagecore storage layerdisk driver (local/s3/r2/spaces)Storage
socketsocket.ioSockets
notifications@warlock.js/notificationsoptional queue worker + brokerNotifications topic
access@warlock.js/accessauthorization resolverAccess topic
ai@warlock.js/aimodel providers (openai/google/anthropic/…) + satellite packagesAI topic

Connectors boot in two passes around your app code, gated by ConnectorLifecyclePhase:

  • Early (the default) — runs before your main.ts / routes.ts / events.ts / model files import. These are the subsystems your code needs at import time: the database must be connected before a model registers its schema, the logger must exist before a module logs. Nine of the eleven built-ins are Early.
  • Late — runs after your app code imports, because it reads what that code just registered. Only http (scans the router your routes.ts files populated) and socket (reads HTTP’s instance) are Late.

Within a single phase the manager calls every connector’s boot() first, then every connector’s start() — that two-stage pass is what lets socket.boot() read the Fastify instance that http.boot() just created.

flowchart LR
    early["Early phase<br/><i>logger · mailer · database · herald · cache · storage · notifications · access</i>"]
    app["App code imports<br/><i>main.ts · routes.ts · events.ts · models</i>"]
    late["Late phase<br/><i>http · socket</i>"]

    early --> app
    app --> late

BaseConnector defaults lifecyclePhase to Early, so a custom connector is Early unless you say otherwise.

Each connector declares a watchedFiles array of relative paths — its own config file, plus anything else whose change should refresh the connection (the mailer connector also watches .env, for instance). In development, the dev server’s file watcher reports changed paths, and the manager asks each connector shouldRestart(changedFiles). When a watched file changes, the manager calls connector.restart(), which on BaseConnector is simply shutdown() then start().

That is why editing src/config/database.ts while the dev server is running reconnects the database without a full restart — the connector tears itself down and stands itself back up reading the new config.

flowchart LR
    save["Save src/config/cache.ts"]
    watch["dev file watcher"]
    should["connector.shouldRestart([...])"]
    restart["connector.restart()<br/><i>= shutdown() then start()</i>"]

    save --> watch
    watch --> should
    should -->|matches watchedFiles| restart

BaseConnector.restart() runs shutdown() then start() and never calls boot(). The socket connector builds its server in boot(), not start(). So editing src/config/socket.ts in dev tears the old Socket.IO server down, but does not stand up a fresh one with the new options — that needs a full dev-server restart to re-run boot(). (The http connector avoids this by overriding restart() to re-run boot() itself, because re-scanning the same Fastify instance would otherwise register every route twice.)

A connector may additionally export a build object (ConnectorBuildContribution) with two optional hooks run by warlock build, in connectors array order, both awaited:

  • generate(context) — write files into the production output directory and/or return extra entry-file import lines. Runs after the generated-imports check and before esbuild bundles. This is how @warlock.js/web gets its pages barrel imported into the generated production entry, and how it emits the page-route manifest routes:diff reads.
  • emit(context) — produce artifacts esbuild itself does not, such as a separate client bundle. Runs after esbuild and before .warlock/production is removed. @warlock.js/web uses this to build the hydration client.

The context handed to both is deliberately narrow — the production directory, the app root, and the resolved build config, nothing that lets a heavy plugin instance or compiled pipeline live on the contribution object itself. Anything heavy is constructed inside the hook, after a dynamic import, so a connector’s static import graph never drags Vite/esbuild plugin instances into the runtime the built app actually ships. A connector that declares an unknown key on build fails the build rather than being silently ignored.

Custom connectors are NOT registered the way the built-ins are — the built-ins are always registered unconditionally, in the ConnectorsManager constructor. The canonical app path is warlock.config.ts > connectors: extend BaseConnector, then list an instance of it in that array. There is no auto-discovery. registerConfiguredConnectors(...) passes the array to the same low-level connectors manager at runtime, while warlock build drains any build contribution from that same array statically. Calling the exported connectorsManager.register(...) directly is a low-level runtime-only escape hatch; it bypasses the build half and is therefore not the app registration path.

warlock.config.ts
import { defineConfig } from "@warlock.js/core";
import { FeatureFlagsConnector } from "./src/connectors/feature-flags.connector";
export default defineConfig({
connectors: [new FeatureFlagsConnector()],
});

The full walkthrough — the four properties and two methods of the contract, picking a priority, choosing a phase, overriding restart(), the graceful-shutdown ordering, and the optional generate/emit build contribution — lives in Bootstrap and connectors.

  • All ten connectors are always registered; the config file is the only activation switch. If a subsystem isn’t running, the connector didn’t fail — there’s just no src/config/<name>.ts for it to read. Check that the config file exists and exports a default config.
  • A missing config is silent. A connector with no matching config returns from start() with no error and no warning. Expected a database connection and didn’t get one? Confirm src/config/database.ts is present.
  • Editing src/config/socket.ts in dev does not apply new socket options. restart() is shutdown() + start() and skips boot(), where the socket server is built. A full dev-server restart is required to pick up new socket options.
  • notifications, access, and ai are real built-ins. Older docs listed only eight connectors; there are eleven. Don’t reuse notifications, access, or ai (or logger, mailer, database, herald, cache, http, storage, socket) as a custom connector name.
  • Bootstrap and connectors — the boot sequence end-to-end and the custom-connector authoring guide.
  • Applicationapp.socket, app.http, app.database, app.router accessors, which read what these connectors store in the container.
  • Configuration — deep dive — how config.get(...) reads the values each connector consults during start().
  • warlock.config.ts — project-level config that runs before any connector starts.