Skip to content

RFC 0001: platform abstraction for non-Kubernetes targets (+ @tsops/vercel skeleton)#52

Draft
Pom4H wants to merge 2 commits into
mainfrom
rfc/0001-platform-abstraction
Draft

RFC 0001: platform abstraction for non-Kubernetes targets (+ @tsops/vercel skeleton)#52
Pom4H wants to merge 2 commits into
mainfrom
rfc/0001-platform-abstraction

Conversation

@Pom4H
Copy link
Copy Markdown
Owner

@Pom4H Pom4H commented May 1, 2026

Summary

Proposes a PlatformAdapter contract on @tsops/core so 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), index
  • rfcs/0000-template.md — RFC template for future proposals
  • rfcs/0001-platform-abstraction.md — the proposal itself

The RFC explicitly addresses:

  • Backwards compatibility (configs without a platform marker behave identically)
  • Four alternatives considered and rejected (status quo / per-app fields / separate configs / external glue)
  • Five unresolved questions (adapter loading, overlay coherence, build artefacts, service-discovery typing, drift policy)
  • Implementation phasing (Phase 1: contract → Phase 2: Vercel v0.1 → Phase 3: API-driven deploys → Phase 4: SD typing)

Reference implementation skeleton

  • packages/vercel/VercelClient port, VercelApi REST adapter (stubs that log + throw), VercelPlanner, VercelDeployer, vercel() helper
  • examples/hybrid-vercel-k8s/ — hybrid topology example showing the target API once Phases 1+2 land
  • docs/guide/vercel.md — design/integration plan referenced from the RFC

The skeleton is parallel to @tsops/core's orchestrator on purpose. Wiring it through TsOps requires the contract changes described in the RFC, which are the actual subject of review here.

Decision points for reviewers

  1. Is the abstraction the right scope? The RFC argues PlatformAdapter is 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.
  2. Naming. "Platform" is overloaded. The RFC discusses target / runtime / backend and explains why platform is the least bad. Counter-proposals welcome.
  3. Adapter loading. Auto-detect from apps.*.platform.kind vs explicit registration in createNodeTsOps. Listed as unresolved.
  4. Service-discovery typing. Proposed but not prototyped. If this isn't tractable, the whole story degrades to "Vercel apps are second-class for config.url".

What this PR is not

  • Not an implementation of the contract. Phase 1 (relaxing TsOps constructor + adding the platform field on AppDefinition) is a follow-up PR if this RFC is accepted.
  • Not a working Vercel integration. VercelApi HTTP methods all throw "not implemented yet". The skeleton exists to validate that the proposed types compose, not to ship features.

Notes

  • The sidebar in .vitepress/config.ts adds a new Platforms → 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.
  • Marked as draft because the RFC needs review before any of the implementation phases start. Take it out of draft once direction is agreed.

Test plan

  • Read rfcs/0001-platform-abstraction.md end-to-end
  • Confirm the VercelClient port surface is sufficient for the planner + deployer (no missing methods)
  • Sanity-check the hybrid example against the proposed API shape
  • pnpm install succeeds (new package registered in tsconfig.json references)
  • pnpm --filter @tsops/vercel build — pre-existing workspace vitest-types issue may cause failure; package source itself type-checks under --skipLibCheck

Generated by Claude Code

claude added 2 commits May 1, 2026 09:35
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PlatformAdapter contract and integration plan.
  • Introduce packages/vercel skeleton (VercelClient port, mapping/diff helpers, planner/deployer, stub VercelApi, and vercel() 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
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
projectId: string
projectId: string
/** Optional team/scope ID used when planning this change. */
teamId?: string

Copilot uses AI. Check for mistakes.
}

/**
* Result of `VercelTsOps.deploy()`.
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* Result of `VercelTsOps.deploy()`.
* Result of applying Vercel changes.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +91
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>
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
})

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +55
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'
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 ?? ''

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,47 @@
{
"name": "@tsops/vercel",
"version": "0.0.0",
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"version": "0.0.0",
"version": "2.0.0",

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +27
"dependencies": {
"@tsops/core": "workspace:*"
},
"keywords": [
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +16
- **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.
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- **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.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +30
* Per-app Vercel options. Attached to an `AppDefinition` via the
* `platform` field (see `index.ts` for the augmentation).
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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.

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +64
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 })
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 []
}

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +68
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'
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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')

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants