/** * The deployment-adapter contract. * * A deployment target = one npm package implementing `DomainAdapter`. * The adapter is GENERIC over its own `Params` so envs AND provider settings * stay 100% adapter-specific (a Cloudflare adapter's `{ route, secrets }` is * nothing like a Node adapter's `{ port }`). The CLI resolves * `envs[] → Params` via `adapter.params(env)` and then drives the adapter * with the resolved params — install of the resulting URL and the `requires` * check are generic and live in the CLI, never in the adapter. * * The central guarantee: `watch()` and `deploy()` both return a `url` — that's * what the CLI prints, what `astrale domain install ` consumes, and * the worker's `iss` identity. The URL is an OUTPUT of watch/deploy; nothing * in the contract asks an adapter to predict it ahead of time. */ /** * Generic, target-independent domain metadata the CLI hands every adapter so * it can codegen the worker entry. (Extends the bare `WatchCtx`/`DeployCtx` of * the spec with the fields a real codegen needs.) */ export interface DomainInfo { /** * The domain's addressing name — the graph slug it mounts under (e.g. * `'crm.acme.dev'`). NOT the cryptographic identity: the JWT `iss` is the * worker's serving URL, pinned by the kernel at install time. */ origin: string /** Cross-domain deps by origin — verified at install (CLI/kernel), not here. */ requires: readonly string[] /** Optional Astrale Path called by the kernel after install (as __SYSTEM__). */ postInstall?: string /** * Whether the domain declares Views / standalone Functions — read from the * `defineDomain` definition, NOT probed from the filesystem. The adapter * codegen imports the corresponding module only when true. */ hasViews: boolean hasFunctions: boolean /** * Views whose binding is backed by a worker-relative SPA mount. This is domain * contract metadata (`defineView({ mount })`), not a frontend source folder: * adapters decide, per env, which client assets serve these mounts. */ mountedViews: Array<{ slug: string; mount: string }> /** * Whether the domain declares a `deps` mapper (`defineDomain({ deps })`). * When true the adapter codegen imports it from the domain's fixed `deps` * module and passes it to the worker entry; when false the worker passes the * raw env straight through as `ctx.deps`. Read from the definition, never * probed from the filesystem. */ hasDeps: boolean } export interface WatchCtx { /** Absolute path to the domain project root. */ projectDir: string /** Absolute path where the CLI writes the diagnostic `spec.json`. */ specPath: string /** Flat secret map loaded from the env's `secrets` file — injected locally in dev. */ secrets: Record /** Domain metadata for codegen. */ domain: DomainInfo /** The env key being watched (e.g. 'dev'). */ env: string /** Invoked by the adapter on every hot-reload so the CLI can re-log. */ onReload(): void } export interface DeployCtx { projectDir: string specPath: string /** Flat secret map loaded from the env's `secrets` file (gitignored). */ secrets: Record domain: DomainInfo /** The env key being deployed (e.g. 'dev' | 'prod' | 'canary'). */ env: string /** * Build the expected install-graph hash for a concrete serving URL from the * current domain definition. Adapters that must verify live drift during * deploy should use this instead of reading `.astrale/spec.json`, which is a * post-deploy diagnostic artifact and may be stale. */ schemaHashForUrl?(url: string): Promise } /** A running local watch — exposes its URL and a stop handle. */ export interface WatchHandle { /** Local URL the worker is reachable at (e.g. http://localhost:8787). */ url: string stop(): Promise } /** A completed deploy — the authoritative public URL. */ export interface DeployResult { url: string /** * Adapter-specific next-steps block printed INSTEAD of the CLI's default * "install on an instance" footer — managed deploys already installed the * domain, so the default hint is wrong there. Pre-indented plain lines. */ nextSteps?: string } export interface DomainAdapter { /** 'astrale-host' | 'cloudflare' | 'node' | … */ name: string /** Resolve an env key to this adapter's typed params. Throws on unknown key. */ params(env: string): Params /** Local + hot-reload (watch) → controllable handle. Orthogonal to env. */ watch(params: Params, ctx: WatchCtx): Promise /** * Optional: re-run codegen for an ALREADY-RUNNING `watch` after * `astrale.config.ts` changed — same shape as `watch` but it must NOT spawn * anything: it only rewrites the generated files, and the running dev server * (which watches them, e.g. `wrangler dev` on its `--config`) picks them up. * Adapters without it fall back to "restart to apply config changes". */ regenerate?(params: Params, ctx: WatchCtx): Promise /** Build + bundle + ship (the adapter owns ITS build) → authoritative URL. */ deploy(params: Params, ctx: DeployCtx): Promise /** * Optional: the path (relative to the project root) of the gitignored secrets * file for these params. The CLI loads it and passes the parsed map as * `ctx.secrets` — keeping secret-file loading generic while the path stays * provider-typed. Return `undefined` for "no secrets file". */ secretsFile?(params: Params): string | undefined } /** * Spec passed to `defineAdapter` — identical to `DomainAdapter` minus the * `params(env)` resolver, which the helper attaches from `envs`. Custom adapter * authors use this so they never re-implement env→params resolution. */ export interface AdapterSpec { name: string /** The provider-typed env map (`{ dev: {...}, prod: {...}, … }`). */ envs: Record watch(params: Params, ctx: WatchCtx): Promise regenerate?(params: Params, ctx: WatchCtx): Promise deploy(params: Params, ctx: DeployCtx): Promise secretsFile?(params: Params): string | undefined } /** * Build a `DomainAdapter` from a spec + an env map, attaching a `params(env)` * resolver that throws a clear error on an unknown key (listing the valid ones). */ export function defineAdapter(spec: AdapterSpec): DomainAdapter { const keys = Object.keys(spec.envs) return { name: spec.name, params(env: string): Params { const params = spec.envs[env] if (params === undefined) { const known = keys.length ? keys.join(', ') : '(none)' throw new Error( `Adapter "${spec.name}": unknown env "${env}". Known envs: ${known}. ` + `Add it to the adapter's env map in astrale.config.ts.`, ) } return params }, watch: spec.watch, deploy: spec.deploy, ...(spec.regenerate ? { regenerate: spec.regenerate } : {}), ...(spec.secretsFile ? { secretsFile: spec.secretsFile } : {}), } }