render-blueprint
v0.1.0
Published
Typed factories that describe Render resources and synthesize render.yaml
Maintainers
Readme
render-blueprint
render-blueprint describes Render.com resources with typed TypeScript factories and synthesizes
them to render.yaml. It is a generator only: it never calls the Render API, and the file it
writes is the state.
Install
npm i -D render-blueprint # npm
pnpm add -D render-blueprint # pnpmThe package is ESM-only. It needs Node 22.18.0 or newer. On Node 23, it needs 23.6.0 or newer.
Quick start
Put this render.ts at the root of your repository:
import { blueprint, postgres, web } from 'render-blueprint';
const db = postgres('records', { plan: 'basic-1gb' });
const api = web('api', {
runtime: 'node',
plan: 'starter',
buildCommand: 'pnpm install --frozen-lockfile && pnpm build',
startCommand: 'pnpm start',
env: { DATABASE_URL: db.connectionString },
});
export default blueprint({ resources: [api, db] });npx render-blueprint synth writes this render.yaml beside it:
# Generated by render-blueprint. Do not edit this file by hand.
# Edit the TypeScript blueprint that produced it and synthesize again.
services:
- type: web
name: api
plan: starter
runtime: node
buildCommand: pnpm install --frozen-lockfile && pnpm build
startCommand: pnpm start
envVars:
- key: DATABASE_URL
fromDatabase:
name: records
property: connectionString
databases:
- name: records
plan: basic-1gbCommand line
npx render-blueprint synth # write render.yaml
npx render-blueprint check # compare the committed render.yaml with the blueprint
npx render-blueprint check --strict # also fail on a warningThe CLI reads the first render.ts, render.mts, render.js or render.mjs from the working
directory up. Its default export must be the value of blueprint(...).
Flags follow the command:
--file <path>— the blueprint file, instead of the one the walk finds.--out <path>— the YAML file to write or to compare. Defaults torender.yamlbeside the blueprint file.--strict— treat a warning as a failure.synthstill writes the file, then fails.--help,-h— print the help.--version,-v— print the installed version.
A --file or --out path resolves against the working directory, not the blueprint.
| Exit code | Meaning |
| --------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 0 | synth wrote the file, or check found the committed file clean. |
| 1 | The blueprint is invalid, a file operation failed, the command line was wrong, or --strict turned a warning into a failure. |
| 2 | The committed file drifted from the blueprint. |
check compares normalized YAML, so a change in formatting alone is not drift.
References and secrets
import {
blueprint,
envGroup,
external,
generated,
literal,
postgres,
readReplica,
secret,
web,
} from 'render-blueprint';
const replica = readReplica('records-reader');
const db = postgres('records', { readReplicas: [replica] });
const settings = envGroup('settings', { env: { SESSION_SECRET: generated() } });
const api = web('api', {
runtime: 'node',
buildCommand: 'pnpm build',
startCommand: 'pnpm start',
env: { LOG_FORMAT: 'json' },
});
const admin = web('admin', {
runtime: 'node',
buildCommand: 'pnpm build',
startCommand: 'pnpm start:admin',
envGroups: [settings],
env: (self) => ({
DATABASE_URL: db.connectionString,
REPLICA_URL: replica.connectionString,
API_LOG_FORMAT: api.envVar('LOG_FORMAT'),
PUBLIC_URL: self.renderVar('RENDER_EXTERNAL_URL'),
LEGACY_AUTH_HOST: external.privateService('legacy-auth').host,
STRIPE_KEY: secret(),
LOG_LEVEL: literal('info', { previewValue: 'debug' }),
}),
});
export default blueprint({ resources: [db, settings, api, admin] });A handle has only the properties that Render can reference, so a worker has no .host.
generated() emits generateValue: true.
Projects and environments
import { blueprint, environment, project, web, worker } from 'render-blueprint';
const api = web('api', { runtime: 'image', image: { url: 'ghcr.io/acme/api:1.4.2' } });
const staging = web('api-staging', { runtime: 'image', image: { url: 'ghcr.io/acme/api:1.5.0' } });
const jobs = worker('jobs', { runtime: 'image', image: { url: 'ghcr.io/acme/jobs:2.0.0' } });
export default blueprint({
projects: [
project('acme', {
environments: [
environment('production', { resources: [api], permissions: { protection: 'enabled' } }),
environment('staging', { resources: [staging] }),
],
}),
],
ungrouped: [jobs],
});A resource goes in exactly one place: resources, one environment, or ungrouped.
Defaults
import { blueprint, withDefaults } from 'render-blueprint';
const acme = withDefaults({
region: 'oregon',
repo: 'https://github.com/acme/mono',
branch: 'main',
rootDir: 'apps/api',
autoDeployTrigger: 'checksPass',
buildFilter: { paths: ['apps/api/**'], ignoredPaths: ['**/*.md'] },
ipAllowList: [{ source: '203.0.113.4/30', description: 'office' }],
plan: { web: 'starter', postgres: 'basic-1gb' },
});
const db = acme.postgres('records');
const api = acme.web('api', {
runtime: 'node',
plan: 'standard', // wins over plan.web
buildCommand: 'pnpm install --frozen-lockfile && pnpm build',
startCommand: 'pnpm start',
env: { DATABASE_URL: db.connectionString },
});
export default blueprint({ resources: [api, db] });Each factory on acme takes the values its kind can hold. plan takes one key per kind. A value
on a resource always wins. Scopes nest: acme.withDefaults(...) replaces only the values it sets.
What it catches
synth and check report every issue at once on stderr:
$ npx render-blueprint check
The blueprint has 3 validation issues; nothing was synthesized.
records.diskSizeGB: A database disk size is 1 GB or a multiple of 5 GB, and Render never shrinks one.
nightly.schedule: A schedule is a cron expression of five fields — minute, hour, day of month, month and day of week, as in "0 2 * * *" — and "every night" is not one.
api.name: More than one resource is named "api". Render identifies a resource by its name, so every name in a blueprint must be unique.
$ echo $?
1With previews on at the root, secret() warns on stderr:
warning api.env.STRIPE_KEY: "api" sets "STRIPE_KEY" with secret() while previews are on. Render does not copy a sync: false variable into a preview environment, so declare it in an environment group the Dashboard manages instead.synth still prints the result on stdout and exits 0:
Wrote /home/acme/mono/render.yamlsrc/validation/issue.ts lists every code.
Names in TypeScript and names in YAML
Every field keeps Render's own name, except these nine.
| TypeScript | YAML |
| ----------------------------------------------------------------- | ----------------------------------------------- |
| privateService(…) | type: pserv |
| staticSite(…) | type: web + runtime: static |
| keyValue(…) | type: keyvalue |
| env: { KEY: value } | envVars: [{ key, … }] |
| envGroups: [g] | envVars: [{ fromGroup }] |
| instances | numInstances |
| secret() | sync: false |
| previews.plan / previews.diskSizeGB on Postgres and Key Value | previewPlan / previewDiskSizeGB |
| extraFields | merged into the mapping, and not a Render field |
Testing
memoryFilePort() keeps files in memory, so a test writes nothing to disk.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { blueprint, checkBlueprint, web, writeBlueprint } from 'render-blueprint';
import { memoryFilePort } from 'render-blueprint/testing';
const value = blueprint({
resources: [web('api', { runtime: 'image', image: { url: 'ghcr.io/acme/api:1.4.2' } })],
});
test('render.yaml matches the blueprint', async () => {
const port = memoryFilePort();
await writeBlueprint(value, { path: 'render.yaml', port });
const drift = await checkBlueprint(value, { path: 'render.yaml', port });
assert.equal(drift.unwrap().status, 'clean');
assert.match(port.files.get('render.yaml') ?? '', /name: api/);
});Programmatic API
synthesize returns a better-result Result. writeBlueprint and checkBlueprint return a
Promise of one.
| Export | What it does |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| synthesize(value) | Validates the blueprint, then returns the YAML text and the warnings. |
| writeBlueprint(value, { path, port }) | Synthesizes the blueprint, then writes the file at path. |
| checkBlueprint(value, { path, port }) | Synthesizes the blueprint, then compares it with the file at path. |
| nodeFilePort | The default port: the real filesystem. |
| BlueprintInvalid | The error that carries every validation issue. |
| DriftReport | The report of checkBlueprint: status: 'clean', or status: 'drift' with a diff and the immutable fields that changed. |
Gotchas
- Import another blueprint file with its
.tsextension. Fortsc, setallowImportingTsExtensionswithnoEmit, orrewriteRelativeImportExtensions. - Node strips the types, so use erasable syntax only, for example: no
enum, no parameter properties, nonamespace. - Without
"type": "module"inpackage.json, Node warnsMODULE_TYPELESS_PACKAGE_JSON. Set the field, or use.mtsfor every blueprint file.
Docs
CONTEXT.md: the glossary.docs/design/: the requirements and the structure.CONTRIBUTING.md: the files and the tests that a change touches.test/fixtures/canonical/: a full two-file blueprint that the tests freeze.
Develop
pnpm install
git config core.hooksPath .githooks # once per clone: the commit hooks
pnpm check # format, lint, typecheck, tests, type tests
pnpm build # the ESM bundle, the types and the bin, into dist/License
MIT
