RFC 0001: platform abstraction for non-Kubernetes targets (+ @tsops/vercel skeleton)#52
RFC 0001: platform abstraction for non-Kubernetes targets (+ @tsops/vercel skeleton)#52Pom4H wants to merge 2 commits into
Conversation
Adds a new package, design doc, and hybrid example that establish the
shape of Vercel support without bending the existing k8s orchestrator.
Package (packages/vercel):
- VercelClient port + VercelApi REST adapter (stubs that log + throw)
- VercelPlanner: diffs desired env/domains vs current Vercel state
- VercelDeployer: applies a VercelChange in safe order (env → domains
→ optional API-triggered deploy)
- mapping.ts: namespace -> Vercel environment, env-var diff, domain diff
- vercel() helper for tagging an app: platform: vercel({ projectId, ... })
Design doc (docs/guide/vercel.md):
- Conceptual mapping between tsops and Vercel concepts
- Two deploy sources (git-driven default vs API-triggered)
- Required core changes to make adapters opt-in and add a platform
discriminator on AppDefinition
- Open questions (cross-platform service discovery, overlays on Vercel,
secret value handling, drift detection)
- Effort estimate: ~1 week for v0.1
Example (examples/hybrid-vercel-k8s):
- Frontend on Vercel, API on Kubernetes, one tsops.config.ts
- Demonstrates the actual reason for the integration: typed
config.url('api', 'ingress') from Vercel-hosted code
The package compiles cleanly (only pre-existing workspace vitest-types
issue remains, unrelated to this change). VercelApi methods all throw
"not implemented yet" so wiring can be exercised before HTTP is filled in.
Introduces an rfcs/ directory and the first RFC, proposing a
PlatformAdapter contract on @tsops/core so apps can target backends
other than Kubernetes (Vercel first, Fly.io / Cloud Run / etc. later)
within the same typed config graph.
The RFC frames the change architecturally rather than as "add Vercel":
- New PlatformAdapter port with plan/apply/describe methods
- Optional platform field on AppDefinition, tagged via { kind: '...' }
- TsOps constructor relaxes its docker/kubectl requirement to be
conditional on whether any app targets Kubernetes
- Backwards-compatible: configs without platform markers behave identically
Documents drawbacks honestly (surface-area growth, naming overload,
service-discovery typing complexity, drift detection without a state
file) and walks through four alternatives that were considered and
rejected.
Includes implementation phasing — phases 1+2 (contract + Vercel v0.1)
are the ~2-week milestone that would unblock the hybrid topology
example added in the previous commit.
There was a problem hiding this comment.
Pull request overview
Adds an RFC process + first RFC proposing a platform abstraction for non-Kubernetes targets, and introduces a @tsops/vercel skeleton package (types/ports/planner/deployer + stub API adapter) plus docs/examples to validate the proposed API shape.
Changes:
- Add RFC workflow docs and RFC 0001 (“platform abstraction”) describing the proposed
PlatformAdaptercontract and integration plan. - Introduce
packages/vercelskeleton (VercelClientport, mapping/diff helpers, planner/deployer, stubVercelApi, andvercel()marker helper). - Add Vercel guide + hybrid example, and wire docs sidebar + TS project references to include the new package.
Reviewed changes
Copilot reviewed 17 out of 19 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Adds packages/vercel to TS project references. |
| pnpm-lock.yaml | Registers packages/vercel as a workspace importer depending on @tsops/core. |
| rfcs/README.md | Defines RFC process/states and an index entry for RFC 0001. |
| rfcs/0000-template.md | Adds an RFC template for future proposals. |
| rfcs/0001-platform-abstraction.md | Adds the detailed platform abstraction RFC (draft). |
| packages/vercel/tsconfig.json | Adds TS build config for the new package. |
| packages/vercel/package.json | Declares new publishable package metadata + scripts + dependency on @tsops/core. |
| packages/vercel/README.md | Documents intended usage/mapping and what the skeleton does/doesn’t implement yet. |
| packages/vercel/src/index.ts | Public exports + vercel() marker helper. |
| packages/vercel/src/types.ts | Defines Vercel platform option types and change/result shapes. |
| packages/vercel/src/ports/vercel.ts | Defines the VercelClient port surface for API/CLI adapters. |
| packages/vercel/src/mapping.ts | Namespace→environment resolution + pure env/domain diff helpers. |
| packages/vercel/src/operations/planner.ts | Planner producing VercelChange by diffing desired vs current Vercel state. |
| packages/vercel/src/operations/deployer.ts | Deployer applying VercelChange (env vars → domains → optional deploy trigger). |
| packages/vercel/src/adapters/api.ts | Stub REST adapter that logs + throws (with partial dryRun behavior). |
| examples/hybrid-vercel-k8s/tsops.config.ts | Hybrid Vercel+K8s example config showing target API shape. |
| examples/hybrid-vercel-k8s/README.md | Explains the hybrid example and current status/limitations. |
| docs/guide/vercel.md | Design doc/guide page for the Vercel adapter skeleton and planned integration. |
| docs/.vitepress/config.ts | Adds sidebar entries for Preview Overlays and Platforms → Vercel (skeleton). |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| */ | ||
| export interface VercelChange { | ||
| app: string | ||
| projectId: string |
There was a problem hiding this comment.
VercelChange doesn’t carry teamId, but VercelPlanner.planApp() uses platform.teamId and VercelDeployer.apply() requires callers to pass teamId separately. This can lead to apply targeting the wrong scope (or failing) unless every caller plumbs teamId out-of-band; consider including teamId in VercelChange (or otherwise making deployer derive it from the planned input).
| projectId: string | |
| projectId: string | |
| /** Optional team/scope ID used when planning this change. */ | |
| teamId?: string |
| } | ||
|
|
||
| /** | ||
| * Result of `VercelTsOps.deploy()`. |
There was a problem hiding this comment.
This refers to VercelTsOps.deploy(), but no VercelTsOps type/class exists in this package. Suggest renaming the reference to the actual entrypoint (VercelDeployer.apply, a future VercelPlatformAdapter, etc.) to avoid confusing consumers.
| * Result of `VercelTsOps.deploy()`. | |
| * Result of applying Vercel changes. |
| export interface TriggerDeploymentOptions { | ||
| /** Vercel environment target. */ | ||
| target: VercelEnvironment | ||
| /** Git ref to deploy (branch, sha, tag). Mutually exclusive with `tarball`. */ | ||
| gitRef?: string | ||
| /** Pre-built tarball URL. Mutually exclusive with `gitRef`. */ | ||
| tarball?: string | ||
| /** Optional human-readable description recorded with the deployment. */ | ||
| meta?: Record<string, string> | ||
| } |
There was a problem hiding this comment.
TriggerDeploymentOptions documents gitRef and tarball as mutually exclusive, but the type currently allows both (or neither). Consider modeling this as a discriminated/union XOR type so misuse is a compile-time error, since the deployer will likely forward it directly to the Vercel API.
| export interface TriggerDeploymentOptions { | |
| /** Vercel environment target. */ | |
| target: VercelEnvironment | |
| /** Git ref to deploy (branch, sha, tag). Mutually exclusive with `tarball`. */ | |
| gitRef?: string | |
| /** Pre-built tarball URL. Mutually exclusive with `gitRef`. */ | |
| tarball?: string | |
| /** Optional human-readable description recorded with the deployment. */ | |
| meta?: Record<string, string> | |
| } | |
| type TriggerDeploymentOptionsBase = { | |
| /** Vercel environment target. */ | |
| target: VercelEnvironment | |
| /** Optional human-readable description recorded with the deployment. */ | |
| meta?: Record<string, string> | |
| } | |
| export type TriggerDeploymentOptions = | |
| | (TriggerDeploymentOptionsBase & { | |
| /** Git ref to deploy (branch, sha, tag). Mutually exclusive with `tarball`. */ | |
| gitRef: string | |
| tarball?: never | |
| }) | |
| | (TriggerDeploymentOptionsBase & { | |
| /** Pre-built tarball URL. Mutually exclusive with `gitRef`. */ | |
| tarball: string | |
| gitRef?: never | |
| }) |
| const token = options.token ?? process.env.VERCEL_TOKEN | ||
| if (!token) { | ||
| throw new Error( | ||
| 'VercelApi requires a token. Set VERCEL_TOKEN or pass `token` explicitly.' | ||
| ) | ||
| } | ||
| this.token = token | ||
| this.defaultTeamId = options.teamId | ||
| this.logger = options.logger | ||
| this.dryRun = options.dryRun ?? false | ||
| this.baseUrl = options.baseUrl ?? 'https://api.vercel.com' |
There was a problem hiding this comment.
dryRun is described as “log API calls but don't execute”, but the constructor still throws if no token is provided. If dryRun is meant to allow exercising planner/deployer wiring without credentials, consider allowing token to be omitted when dryRun: true (or clarify that dryRun still requires a token).
| const token = options.token ?? process.env.VERCEL_TOKEN | |
| if (!token) { | |
| throw new Error( | |
| 'VercelApi requires a token. Set VERCEL_TOKEN or pass `token` explicitly.' | |
| ) | |
| } | |
| this.token = token | |
| this.defaultTeamId = options.teamId | |
| this.logger = options.logger | |
| this.dryRun = options.dryRun ?? false | |
| this.baseUrl = options.baseUrl ?? 'https://api.vercel.com' | |
| this.defaultTeamId = options.teamId | |
| this.logger = options.logger | |
| this.dryRun = options.dryRun ?? false | |
| this.baseUrl = options.baseUrl ?? 'https://api.vercel.com' | |
| const token = options.token ?? process.env.VERCEL_TOKEN | |
| if (!token && !this.dryRun) { | |
| throw new Error( | |
| 'VercelApi requires a token. Set VERCEL_TOKEN or pass `token` explicitly.' | |
| ) | |
| } | |
| this.token = token ?? '' |
| @@ -0,0 +1,47 @@ | |||
| { | |||
| "name": "@tsops/vercel", | |||
| "version": "0.0.0", | |||
There was a problem hiding this comment.
This package’s version is 0.0.0 while the rest of the published workspace packages are 2.0.0. If @tsops/vercel is intended to release alongside the rest of the monorepo, align its versioning strategy (and consider adding it to the Changesets fixed group); otherwise, document why it’s intentionally versioned independently.
| "version": "0.0.0", | |
| "version": "2.0.0", |
| "dependencies": { | ||
| "@tsops/core": "workspace:*" | ||
| }, | ||
| "keywords": [ |
There was a problem hiding this comment.
Repo convention (see AGENTS.md) is to record public changes via Changesets. Adding a new publishable package (@tsops/vercel) looks like a public surface-area addition, but this PR doesn’t include a .changeset/* entry. Consider adding one (even if the initial release is 0.x) so the release workflow can pick it up intentionally.
| - **One typed config covers both platforms.** `web` uses `platform: vercel(...)`; `api` uses the default Kubernetes flow. | ||
| - **Cross-platform service discovery is type-safe.** The Next.js frontend on Vercel imports the same `tsops.config.ts` and calls `config.url('api', 'ingress')`. Renaming `api` is a compile error in `web`. | ||
| - **Per-platform deploy semantics.** `tsops plan` produces two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api`. `tsops deploy` dispatches to the right backend per app. |
There was a problem hiding this comment.
The README states the frontend “calls config.url('api', 'ingress')” and that service discovery is already type-safe, but the checked-in config/example code currently hardcodes the API URL and the Vercel integration is described as incomplete. Consider clarifying this bullet as a target-state (or linking directly to where the type-safe call exists) to avoid implying it works today.
| - **One typed config covers both platforms.** `web` uses `platform: vercel(...)`; `api` uses the default Kubernetes flow. | |
| - **Cross-platform service discovery is type-safe.** The Next.js frontend on Vercel imports the same `tsops.config.ts` and calls `config.url('api', 'ingress')`. Renaming `api` is a compile error in `web`. | |
| - **Per-platform deploy semantics.** `tsops plan` produces two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api`. `tsops deploy` dispatches to the right backend per app. | |
| - **One typed config can cover both platforms.** In the finished integration, `web` would use `platform: vercel(...)`; `api` would use the default Kubernetes flow. | |
| - **Cross-platform service discovery is intended to be type-safe.** In the finished integration, the Next.js frontend on Vercel would import the same `tsops.config.ts` and call `config.url('api', 'ingress')`, so renaming `api` would become a compile error in `web`. | |
| - **Per-platform deploy semantics are the target behavior.** Once the Vercel adapter is implemented, `tsops plan` would produce two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api` — and `tsops deploy` would dispatch to the right backend per app. |
| * Per-app Vercel options. Attached to an `AppDefinition` via the | ||
| * `platform` field (see `index.ts` for the augmentation). |
There was a problem hiding this comment.
The comment says this is “attached to an AppDefinition via the platform field (see index.ts for the augmentation)”, but there’s no module augmentation in this package (no declare module), only a vercel() factory. Please either add the intended type augmentation or adjust the comment so consumers aren’t looking for a mechanism that doesn’t exist.
| * Per-app Vercel options. Attached to an `AppDefinition` via the | |
| * `platform` field (see `index.ts` for the augmentation). | |
| * Per-app Vercel options. | |
| * | |
| * Use this as the shape of the Vercel-specific value supplied for an app's | |
| * `platform` configuration. |
| this.logger.debug('vercel.getProject', { projectId, teamId: teamId ?? this.defaultTeamId }) | ||
| throw new Error('VercelApi.getProject: not implemented yet') | ||
| } | ||
|
|
||
| async listEnvVars(projectId: string, teamId?: string): Promise<VercelEnvVar[]> { | ||
| this.logger.debug('vercel.listEnvVars', { projectId, teamId: teamId ?? this.defaultTeamId }) |
There was a problem hiding this comment.
For consistency with the dryRun option, getProject / listEnvVars / listDomains currently always throw, which makes it impossible to run planning in a dry-run mode. Either have these methods also return safe stub values when dryRun is true, or remove dryRun until it’s fully supported.
| this.logger.debug('vercel.getProject', { projectId, teamId: teamId ?? this.defaultTeamId }) | |
| throw new Error('VercelApi.getProject: not implemented yet') | |
| } | |
| async listEnvVars(projectId: string, teamId?: string): Promise<VercelEnvVar[]> { | |
| this.logger.debug('vercel.listEnvVars', { projectId, teamId: teamId ?? this.defaultTeamId }) | |
| this.logger.debug('vercel.getProject', { projectId, teamId: teamId ?? this.defaultTeamId }) | |
| if (this.dryRun) { | |
| this.logger.debug('vercel.getProject.dryRun', { | |
| projectId, | |
| teamId: teamId ?? this.defaultTeamId, | |
| result: null | |
| }) | |
| return null | |
| } | |
| throw new Error('VercelApi.getProject: not implemented yet') | |
| } | |
| async listEnvVars(projectId: string, teamId?: string): Promise<VercelEnvVar[]> { | |
| this.logger.debug('vercel.listEnvVars', { projectId, teamId: teamId ?? this.defaultTeamId }) | |
| if (this.dryRun) { | |
| this.logger.debug('vercel.listEnvVars.dryRun', { | |
| projectId, | |
| teamId: teamId ?? this.defaultTeamId, | |
| count: 0 | |
| }) | |
| return [] | |
| } |
| env: ({ secret }) => ({ | ||
| SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'), | ||
| // Build-time URL for the API — resolves to the k8s ingress. | ||
| // This is the whole reason for the typed config: when `api` is | ||
| // renamed, this line is a compile error. | ||
| NEXT_PUBLIC_API_URL: 'https://api.example.com' |
There was a problem hiding this comment.
This example claims the API URL is type-safe and will be a compile error on rename, but it’s currently hardcoded. Since env resolvers already receive a url(...) helper from tsops core, consider setting NEXT_PUBLIC_API_URL via url('api', 'ingress') (or adjust the comment if this is intentionally placeholder).
| env: ({ secret }) => ({ | |
| SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'), | |
| // Build-time URL for the API — resolves to the k8s ingress. | |
| // This is the whole reason for the typed config: when `api` is | |
| // renamed, this line is a compile error. | |
| NEXT_PUBLIC_API_URL: 'https://api.example.com' | |
| env: ({ secret, url }) => ({ | |
| SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'), | |
| // Build-time URL for the API — resolves to the k8s ingress. | |
| // This is the whole reason for the typed config: when `api` is | |
| // renamed, this line is a compile error. | |
| NEXT_PUBLIC_API_URL: url('api', 'ingress') |
Summary
Proposes a
PlatformAdaptercontract on@tsops/coreso apps can target deployment backends other than Kubernetes (Vercel first; Fly.io / Cloud Run / Workers / etc. later) within the same typed config graph. Ships the RFC alongside a reference-implementation skeleton (@tsops/vercel) so reviewers can evaluate the proposal against real types, not pseudocode.This is not "let's add Vercel". The framing is architectural: should tsops have a platform abstraction at all? Vercel is the first concrete instance.
Why now
The single largest piece of value tsops delivers is one typed config imported by both the manifest builder and the application code. That value compounds when the same config covers a hybrid topology — e.g. frontend on Vercel, backend on Kubernetes. Today users in that situation maintain two configs and hardcode cross-system URLs, and the compiler can't help with either.
The motivation, alternatives, drawbacks, and unresolved questions are spelled out in
rfcs/0001-platform-abstraction.md. Please review there.What's in this PR
RFC
rfcs/README.md— process (Draft → Accepted → Implemented), indexrfcs/0000-template.md— RFC template for future proposalsrfcs/0001-platform-abstraction.md— the proposal itselfThe RFC explicitly addresses:
platformmarker behave identically)Reference implementation skeleton
packages/vercel/—VercelClientport,VercelApiREST adapter (stubs that log + throw),VercelPlanner,VercelDeployer,vercel()helperexamples/hybrid-vercel-k8s/— hybrid topology example showing the target API once Phases 1+2 landdocs/guide/vercel.md— design/integration plan referenced from the RFCThe skeleton is parallel to
@tsops/core's orchestrator on purpose. Wiring it throughTsOpsrequires the contract changes described in the RFC, which are the actual subject of review here.Decision points for reviewers
PlatformAdapteris the level to draw the line at. Alternatives section spells out why per-app fields or separate configs aren't enough. Push back here if you disagree — this is the load-bearing decision.target/runtime/backendand explains whyplatformis the least bad. Counter-proposals welcome.apps.*.platform.kindvs explicit registration increateNodeTsOps. Listed as unresolved.config.url".What this PR is not
TsOpsconstructor + adding theplatformfield onAppDefinition) is a follow-up PR if this RFC is accepted.VercelApiHTTP methods all throw "not implemented yet". The skeleton exists to validate that the proposed types compose, not to ship features.Notes
.vitepress/config.tsadds a newPlatforms → Vercel (skeleton)entry pointing at the new guide. Pre-existing broken sidebar entries (/guide/configuration,/guide/networking, etc.) are not touched here — docs: refresh README + guides; reframe around the typed operational model #51 fixes those.Test plan
rfcs/0001-platform-abstraction.mdend-to-endVercelClientport surface is sufficient for the planner + deployer (no missing methods)pnpm installsucceeds (new package registered intsconfig.jsonreferences)pnpm --filter @tsops/vercel build— pre-existing workspace vitest-types issue may cause failure; package source itself type-checks under--skipLibCheckGenerated by Claude Code