Skip to content

Infrastructure Abstraction (IaC)

CruzJS describes infrastructure with logical bindingssql, kv, blob, queue, llm, and so on — instead of naming a cloud service. A target adapter resolves each binding into a concrete native service and emits the matching infrastructure-as-code artifact (today: a Cloudflare wrangler.jsonc). This is the engine behind the multi-cloud roadmap: the same config is meant to compile onto different clouds without touching application code.

Implementation lives in packages/cli/src/infra/.

A binding declares a capability the app needs, not a product. The supported binding types:

TypeCapabilityCloudflare native
sqlRelational databaseD1
kvKey-value cacheKV
blobObject storageR2
queueAsync messaging (work or fanout)Queues
vectorVector index (dims)Vectorize
searchFull-text searchFTS5 (D1)
cronScheduled jobs (schedule)Cron Triggers
realtimeWebsockets / pub-subDurable Objects
llmAI inferenceWorkers AI
emailTransactional email— (provider)

A minimal config — target-agnostic:

const config: InfraConfig = {
name: 'cruzjs',
bindings: {
db: { type: 'sql', engine: 'sqlite' },
cache_kv: { type: 'kv' },
storage: { type: 'blob' },
ai: { type: 'llm' },
},
};

The logical name (db, cache_kv, …) becomes the binding variable the runtime reads. cache_kvCACHE_KV, storageSTORAGE, etc.

Most apps only need rung 0. The higher rungs are there when you need to reach past the abstraction — without abandoning it. Each rung is an optional field on the binding spec (bindings.ts):

RungNameFieldWhat it does
0Logicaltype + engineDeclare the capability.
1Portable tuningsize, scale, region, dims, schedule, modeCloud-neutral knobs (e.g. scale: { min: 0 } for scale-to-zero intent).
2Service swaptargets.<t>.servicePick a different native service within a cloud.
3Native knobstargets.<t>.optionsService-specific options merged onto the resolved resource.
4Raw patchtargets.<t>.rawRaw IaC merged into the generated output. Declarative targets take an object (deep-merged); imperative targets take a mutator function.
5BYO resourceexternal.<t>Point at an existing resource; the adapter binds but does not provision it.

Example climbing to rung 4 (point D1 at a dialect-specific migrations dir):

db: {
type: 'sql',
engine: 'sqlite',
targets: {
cloudflare: { raw: { migrations_dir: './src/database/migrations/sqlite' } },
},
},

A second axis, sourcing (provider), is orthogonal to the target: 'native' (the cloud’s own service, default), 'self' (a container you run), or a SaaS name like 'neon' / 'upstash'.

compileInfra() (orchestrate.ts) takes an InfraConfig + a target + an env and runs:

resolve → negotiate → [provision] → generate → emit
  • resolve (resolver.ts) — reconcile each binding against the target’s CapabilityManifest. Decide the native service, apply rung 1/3 tuning and the rung 4 raw patch, and record a sourcing of native | saas | self | external.
  • negotiate — a required binding with no fit fails the build; an optional one is skipped with a warning; a fallback map supplies an alternative.
  • generate — the adapter’s generate() serializes resolved bindings into IaC.
  • emit — artifacts written to .cruz/build/<env>-<target>/.

Provisioned resource ids are persisted to .cruz/state/<env>-<target>.json (state.ts) so redeploys reuse rather than recreate. Each binding tracks:

  • managedfalse means ejected / externally owned.
  • service — resolved service id (d1, kv, …).
  • resource{ name?, id? } of the provisioned resource.

Each target implements a TargetAdapter (adapter.ts): a CapabilityManifest declaring which native service backs each binding type, plus a generate() function. Cloudflare is the only target wired end-to-end today; AWS (CDK), GCP (Cloud Run), Azure (Bicep), DigitalOcean (App Spec), and Docker (compose) are roadmap targets.

The Cloudflare manifest (targets/cloudflare.ts):

capabilities: {
sql: { native: { sqlite: 'd1' }, services: ['d1'] },
kv: { native: { '*': 'kv' } },
blob: { native: { '*': 'r2' } },
llm: { native: { '*': 'workers-ai' } },
}
iac: { format: 'wrangler', emit: 'wrangler.jsonc' }

apps/demo/scripts/ship-infra.ts drives the whole pipeline for the demo app:

// 1. Seed state with the existing prod resource ids (reuse, don't recreate).
writeState(rootDir, {
target: 'cloudflare',
env: 'production',
bindings: {
db: { managed: true, service: 'd1', resource: { id: 'e95285ce-…' } },
cache_kv: { managed: true, service: 'kv', resource: { id: '2794a654…' } },
storage: { managed: true, service: 'r2', resource: { name: 'cruzjs-production-docs' } },
},
});
// 2. Logical, target-agnostic bindings.
const config: InfraConfig = {
name: 'cruzjs',
bindings: {
db: { type: 'sql', engine: 'sqlite',
targets: { cloudflare: { raw: { migrations_dir: './src/database/migrations/sqlite' } } } },
cache_kv: { type: 'kv' },
storage: { type: 'blob' },
ai: { type: 'llm' },
},
};
// 3. Compile for Cloudflare → wrangler.jsonc.
const out = await compileInfra({ rootDir, config, target: 'cloudflare', env: 'production', gen: { /* … */ } });
writeFileSync('wrangler.jsonc', out.artifact.files.find((f) => f.path === 'wrangler.jsonc').content);

The resolver maps blob → r2, kv → kv, sql(sqlite) → d1, llm → workers-ai, and emits a wrangler.jsonc with the matching d1_databases, kv_namespaces, r2_buckets, and [ai] blocks — reusing the seeded ids.