KoalaSoft.Aspire.Hosting.ServiceSources 0.3.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --version 0.3.0
                    
NuGet\Install-Package KoalaSoft.Aspire.Hosting.ServiceSources -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add KoalaSoft.Aspire.Hosting.ServiceSources --version 0.3.0
                    
#r "nuget: KoalaSoft.Aspire.Hosting.ServiceSources, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package KoalaSoft.Aspire.Hosting.ServiceSources@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.3.0
                    
Install as a Cake Tool

Aspire.Hosting.ServiceSources

NuGet Downloads License: MIT

A .NET Aspire AppHost extension that lets builder.AddService("orders") resolve to a real, running resource whose source is chosen per developer, not baked into the AppHost.

Why

AddProject<T>() assumes a service lives in the AppHost's own solution. In a real microservice environment, services live in separate repositories, and different developers want different things for the same service: clone it locally to edit, run it from an already-checked-out working copy, reach an instance already running in a shared Kubernetes dev cluster, hit a fixed URL, or just run a published container image. The AppHost should only describe what it depends on; where that dependency actually comes from is a per-developer choice, made without ever touching the AppHost's .csproj/.sln.

AddService() is the seam: the AppHost calls it once per service, and a developer-local config file decides how it's actually resolved — a managed or self-managed local git checkout ("local"), a kubectl port-forward against a dev cluster ("kubernetes"), a fixed, already-known URL ("url"), or a published container image run locally ("container") — behind one stable return type, so the AppHost code never has to change when a developer switches sources.

Install

Published on nuget.org as KoalaSoft.Aspire.Hosting.ServiceSources. If every service your AppHost declares is a .NET project, this is the only package you need:

dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources

These packages floor Aspire at 13.5.2, so an AppHost still on 13.4.x gets a mixed Aspire family. NuGet takes the highest floor, so Aspire.Hosting is lifted to 13.5.2 while your Aspire.AppHost.Sdk, Aspire.Hosting.AppHost and the DCP and dashboard packages the SDK pins to it stay where they are. Nothing warns about it at restore. Move your AppHost's own Aspire version to 13.5.2 or later at the same time:

<Sdk Name="Aspire.AppHost.Sdk" Version="13.5.2" />

Services that aren't .NET projects need the satellite package for their language, so an AppHost only takes on the hosting dependencies it actually uses — see Non-.NET local services:

Language Package
Java KoalaSoft.Aspire.Hosting.ServiceSources.Java
JavaScript KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript

A satellite already depends on the core package, so add it instead of the core package rather than alongside it — restore brings the matching core in for you:

dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript

Two direct references mean two versions to move in step, because a satellite accepts core only within its own minor: bump one and not the other and restore fails with NU1107. A single reference has nothing to keep in step. Add a satellite per language you use; core still arrives once, transitively.

Or reference the project directly from your AppHost instead:

<ItemGroup>
  <ProjectReference Include="path/to/Aspire.Hosting.ServiceSources/Aspire.Hosting.ServiceSources.csproj" />
</ItemGroup>

Requires .NET 8 or later (net8.0, net9.0, and net10.0 are all supported) and an AppHost project using the Aspire.AppHost.Sdk (aspire new / aspire restore sets this up).

Every release is listed in the changelog, which is where breaking changes and their migrations are recorded. Check it before upgrading — while the version is below 1.0.0, a breaking change can ship in a minor release.

Preview builds

Every push to main publishes a prerelease build (0.x.y-alpha.0.N) to GitHub Packages. Stable releases go to nuget.org only — use those unless you specifically need an unreleased fix. Previews are pruned after each release — only the five most recent are kept — so treat them as disposable and never pin one in a long-lived project.

GitHub's NuGet registry requires authentication for every download, even for public packages — unlike the container registry, it has no anonymous access. This is not a grant on this repository: any authenticated GitHub user can download a public package, so all you need is a token on your own account. It must be a classic personal access token with the read:packages scope; fine-grained tokens are not supported by GitHub Packages.

dotnet nuget add source https://nuget.pkg.github.com/flojon/index.json \
  --name servicesources-preview --username <your-github-username> --password <your-pat>
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript --prerelease

Add the satellite here too, not core alongside it — the feed carries a prerelease of all three packages per commit, so two direct references are two prereleases to keep in step. If you use no satellite at all, dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --prerelease is the single reference to add.

Getting started

1. Declare the service in Program.cs:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

var orders = builder.AddService("orders");
var api = builder.AddProject<Projects.Api>("api")
    .WithReference(orders);

builder.Build().Run();

2. Add the shared catalog, servicesources.yaml, next to the AppHost project (commit this file):

services:
  orders:
    repository: https://github.com/example/orders
    project: src/Orders.Api/Orders.Api.csproj
    defaultRef: main          # optional; branch, tag, or commit SHA

(A service that isn't a .NET project also takes a kind — see Non-.NET local services.)

3. Add your own servicesources.local.json next to it (gitignore this file — it's per-developer):

{
  "services": {
    "orders": { "source": "local" }
  }
}

That's it — running the AppHost now clones orders into <AppHostDirectory>/.servicesources/checkouts/orders/, checks out main, and runs it via Aspire's own project orchestration, wired up to api through service discovery exactly like a project reference would be.

"local" source options

{
  "services": {
    "orders": { "source": "local" },
    "payments": {
      "source": "local",
      "path": "/home/dev/code/payments",
      "ref": "feature/new-checkout"
    }
  }
}
  • Omit path for a managed checkout: cloned once into <AppHostDirectory>/.servicesources/checkouts/<serviceName>/, and reconciled to the configured ref (or the catalog's defaultRef) on every run. Uncommitted edits are never discarded — if the checkout is dirty and the ref changed, resolution fails loudly instead of overwriting your work. Anything you put at that path yourself that isn't a plain clone — a linked git worktree, or a clone made with --separate-git-dir — is refused with an explanation rather than replaced; point at it with path instead. A directory there with no .git entry at all is treated as debris from an interrupted clone and deleted, so don't hand-place a plain directory as a quick override — use path for that too. The .servicesources/ directory gitignores itself on first use — no need to add it to your own .gitignore.
  • Set path to point at a checkout you manage yourself (e.g. an existing local clone). It's used as-is — no clone, no checkout, no fetch, ever. A relative path is anchored to the AppHost directory, and must name a directory that already exists. ref cannot be combined with path.
  • Keep the file to the services you actually add. AddService() has to hand back the real resource, so it can't wait until the AppHost has finished composing to find out which services it wants — the first call clones the checkouts for every "local" entry, in parallel. Only the services you actually add are then reconciled to their configured ref: a checkout that already exists is never touched on behalf of an entry you don't AddService(), so work in progress on a branch there is safe. Entries you never add still cost network and disk for that first clone. The AppHost logs which ones those were at startup — and warns if one of them failed, since nothing else would ever tell you — so you know what to drop.
Several services from one repository

A catalog entry maps one service to one thing to run, so a repository holding several services gets one entry per service — each naming the same repository, and each selecting its own part of the tree (project for the default dotnet kind, or the kind's own options block, such as appDirectory, for the kinds below):

services:
  orders:
    repository: https://github.com/example/monorepo
    project: src/Orders.Api/Orders.Api.csproj
    defaultRef: main
  payments:
    repository: https://github.com/example/monorepo
    project: src/Payments.Api/Payments.Api.csproj
    defaultRef: main

The catalog is the same either way; what differs is how many checkouts of that repository end up on your machine, which each developer chooses in servicesources.local.json:

  • One managed checkout per service — omit path. Managed checkouts are keyed by service name, so orders and payments each get their own independent clone of the repository, at .servicesources/checkouts/orders/ and .servicesources/checkouts/payments/. Each can sit on its own ref and neither can disturb the other, but the repository is cloned once per service, and an edit to shared code in one checkout is invisible to the other.

  • One checkout shared by every service — set path. Clone the repository yourself, then point each service at that same directory; the entry's project (or appDirectory) is resolved relative to it:

    {
      "services": {
        "orders":   { "source": "local", "path": "/home/dev/code/monorepo" },
        "payments": { "source": "local", "path": "/home/dev/code/monorepo" }
      }
    }
    

    This is usually what you want when the services share code: one clone, one branch, and an edit to a shared project is picked up by every service at once. The trade-off is that the clone is yours to manage — nothing is ever cloned, fetched or checked out on your behalf — and ref cannot be combined with path.

Mixing the two is fine: services you're actively editing can share one path checkout while the rest stay on managed clones.

Non-.NET local services: kind

A "local" service is resolved as a .NET project by default. Set kind in the catalog to run the checkout some other way — the git clone/checkout is identical, only what gets built out of the resulting directory changes:

services:
  frontend:
    repository: https://github.com/example/frontend
    kind: javascript          # optional; defaults to "dotnet"
    javascript:               # per-kind options block, named after the kind
      appDirectory: .
      runScript: dev

kind: dotnet (the default) uses the entry's project property and needs no options block. Any other kind is resolved by a handler that a satellite package registers, and its options live in a block named after the kind. Kind names are matched case-sensitively, and a kind with no registered handler fails at that service's AddService() call, before its checkout is used.

JavaScript: kind: javascript

Provided by the KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript package, which runs the checkout through Aspire.Hosting.JavaScript. Install it, then call UseJavaScript() once, before the first AddService() call:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

builder.UseJavaScript();

var frontend = builder.AddService("frontend");
services:
  frontend:
    repository: https://github.com/example/frontend
    kind: javascript
    javascript:
      appType: vite         # javascript (default) | vite | nextjs | node | bun
      appDirectory: web     # directory holding package.json, relative to the repo root
      runScript: dev        # package.json script to run
      packageManager: pnpm  # npm | yarn | pnpm | bun
      port: 4321            # the port consumers reach the service on

Keep Aspire.Hosting.JavaScript on the same version as Aspire.Hosting. Aspire releases the two together and tests them that way. They were also coupled across a friend-assembly boundary until 13.5.0: Aspire.Hosting.JavaScript 13.4.6 against Aspire.Hosting 13.5.x restores and compiles clean, then throws MethodAccessException the first time a kind: javascript service resolves. This package floors both at 13.5.2, so you get a matched pair by default. If you raise Aspire.Hosting past that on its own, add a reference at whatever version your AppHost resolves for it — the version below is an example, not a version to copy:

<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.5.3" />

Every option is optional:

  • appType — which integration runs the app: javascript (the default, AddJavaScriptApp), vite, nextjs, node, or bun. node and bun execute a file directly rather than a package.json script, so they require scriptPath; the other three run a script and reject it.
  • appDirectory — the directory holding the app's package.json, relative to the repository root, which is also the default. It must stay inside the checkout, and — for every app type that runs a package.json script — it is checked to actually hold one, so pointing it at the wrong directory of a monorepo is reported against the service rather than surfacing later as an npm could not read package.json.
  • runScript — the package.json script to run; the integrations default this to dev. For node/bun it overrides the scriptPath they would otherwise execute directly, which needs a package.json in appDirectory — without one those two app types run scriptPath and nothing else, so a runScript set there is rejected rather than silently ignored.
  • scriptPath — the entry-point file (e.g. server.js) relative to appDirectory. Required by appType: node and appType: bun, and rejected for the others. Like appDirectory it must stay inside the checkout, and it is checked to exist so a typo is reported against the service rather than surfacing later as a cannot find module crash.
  • packageManagernpm, yarn, pnpm, or bun, used to install dependencies before the app starts (a fresh clone has no node_modules). Left unset, the integration's own default applies: npm for most app types, Bun for appType: bun.
  • port / targetPort — the port consumers reach the service on, and the port the app itself listens on. Both are allocated by Aspire when unset.
  • portEnv — the environment variable the app reads its listen port from; defaults to PORT. Rejected for vite/nextjs, whose integrations bind the dev server's port themselves.

The service always gets an http endpoint, so the builder AddService() returns can be passed to a consumer's WithReference(...) like any other. Node and Bun must be on PATH for the app types that use them.

Java: kind: java

Provided by the KoalaSoft.Aspire.Hosting.ServiceSources.Java package, which runs the checkout through the .NET Aspire Community Toolkit's Java integration. Install it, then call UseJava() once, before the first AddService() call — AddService() resolves eagerly, so a kind: java service registered after it has already run has nowhere to look up its handler:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

builder.UseJava();

var catalog = builder.AddService("catalog");

servicesources.yaml:

services:
  catalog:
    repository: https://github.com/example/catalog
    kind: java
    java:
      mavenGoal: spring-boot:run
      port: 8080

The checkout is cloned exactly as for any other "local" service (path, ref, and defaultRef all behave identically), then handed to that integration to run.

java: block options

Field Required Description
mavenGoal one of these three Run via the Maven wrapper, e.g. spring-boot:run.
gradleTask one of these three Run via the Gradle wrapper, e.g. bootRun.
jarPath one of these three Run a pre-built jar with java -jar, relative to workingDirectory. May climb out of it — a monorepo's shared build output directory — but must stay inside the checkout.
port yes The port the app listens on. Becomes the service's HTTP endpoint, so consumers can WithReference(...) it.
workingDirectory no (defaults to the repository root) Where in the checkout the project lives — the directory holding pom.xml / build.gradle, and by default the mvnw/gradlew wrapper too. Must stay inside the checkout.
wrapperPath no (defaults to the wrapper in workingDirectory) Where the mvnw/gradlew wrapper script lives, relative to the repository root — for the monorepo that commits a single wrapper at its root while the service itself sits further down. Name it without an extension (gradlew, not gradlew.bat) and it works for the whole team: on Windows the .cmd/.bat wrapper beside it is the one run. Only meaningful with mavenGoal or gradleTask.
args no Extra arguments for whichever run mode is configured — passed to the Maven wrapper, the Gradle wrapper, or the jar.

mavenGoal, gradleTask, and jarPath are mutually exclusive: exactly one must be set. A monorepo service, running a Gradle task with an extra argument:

services:
  catalog:
    repository: https://github.com/example/monorepo
    kind: java
    java:
      workingDirectory: services/catalog
      gradleTask: bootRun
      wrapperPath: gradlew
      args: ["--args=--spring.profiles.active=dev"]
      port: 8080

A multi-project Gradle repository (like a multi-module Maven one) commits a single wrapper at its root rather than one per project, which is what wrapperPath: gradlew names here — without it the wrapper is looked for in services/catalog, beside the project.

mavenGoal and gradleTask run the repository's own mvnw/gradlew wrapper, so a JDK must be on the developer's machine but Maven/Gradle itself need not be. That wrapper has to be in the checkout — there is no fallback to a system-wide mvn/gradle — so a checkout without one is reported as such, rather than left to surface as a failure to start the app. On Windows the wrapper run is mvnw.cmd/gradlew.bat, whether it was found by default or named by wrapperPath: the extensionless scripts beside them are POSIX shell scripts that Windows cannot exec.

Every problem with the block bar two — unknown properties, a missing or out-of-range port, no run mode or more than one, a workingDirectory, wrapperPath or jarPath escaping the repository, a wrapperPath set alongside jarPath — is reported by the AddService("catalog") call itself, before the service has added anything to the app model. The two exceptions are a workingDirectory that doesn't exist in the checkout and a wrapper script that isn't there: both need the checkout on disk, which isn't cloned until the block itself has been checked, so they are reported a moment later, once the resource is being created.

Reaching the rest of the Java integration. The java: block covers how to start the app; it deliberately doesn't mirror every modifier the Community Toolkit offers. Anything else is reachable from the AppHost with As<JavaAppExecutableResource>(), which hands back the real resource builder:

builder.AddService("catalog")
    .As<JavaAppExecutableResource>()
    .WithMavenBuild()                      // compile before starting
    .WithJvmArgs(["-Xmx512m"])
    .WithOtelAgent("/path/to/opentelemetry-javaagent.jar");

Use Configure<T>(...) instead for anything that should survive a developer switching that service to a non-local source — As<T>() throws if the service no longer resolves to a Java resource, which is the point when the AppHost genuinely requires one.

UseJava() is exported to Aspire's Type System, so a TypeScript AppHost can call useJava() before addService(...) the same way.

Implementing a kind

A satellite package implements ILocalResourceKind and registers it from its own extension method:

public sealed class JavaScriptKind : ILocalResourceKind
{
    private sealed class Options
    {
        public string? AppDirectory { get; set; }
        public string? RunScript { get; set; }
    }

    // Optional, and worth implementing whenever Resolve parses rawConfig: this runs immediately
    // before Resolve, and before this service's checkout, so a typo'd options block is reported
    // without a half-created resource behind it and without paying for a clone first.
    public void Validate(string serviceName, object? rawConfig) =>
        LocalKindConfig.Parse<Options>(rawConfig, serviceName);

    public IResourceBuilder<IResourceWithServiceDiscovery> Resolve(
        IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
    {
        // repoRoot is the already-cloned, already-checked-out directory.
        var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
        ...
    }
}

public static IDistributedApplicationBuilder UseJavaScript(this IDistributedApplicationBuilder builder) =>
    builder.AddLocalKind("javascript", new JavaScriptKind());

LocalKindConfig.Parse<T> turns the opaque options block into a typed object, and rejects an unknown property or a block that isn't a mapping with a ServiceSourcesConfigurationException naming the service. AddLocalKind must be called before the AddService() call for a service of that kind — resolution is eager, so registering later is too late — accepts each kind name at most once, and cannot re-register "dotnet" or use a name that collides with a well-known service property (repository, project, defaultRef, kind, kubernetes, url, container) — a block by one of those names would be read as that property rather than as the kind's options.

Private repositories

Clone and fetch for a managed checkout (no path override) authenticate the same way, in order:

  1. Your git credential helper. The managed checkout shells out to git credential fill for the repository's host, so whatever you already have configured — Git Credential Manager, osxkeychain, libsecret, a cached PAT, a .netrc-backed helper — is reused automatically. Nothing to configure here beyond having git on PATH with a working credential helper (run git credential fill yourself against the same host to confirm it resolves before wiring it up here).
  2. SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKEN environment variables, if the helper above yields nothing (e.g. no helper configured, or git isn't on PATH) — or if what it yielded was refused, see below. SERVICESOURCES_GIT_TOKEN alone is enough for hosts that accept any username alongside a personal access token (GitHub, GitLab, Azure DevOps); set SERVICESOURCES_GIT_USERNAME too if your host requires a specific one.

The order is a ladder, not a one-shot choice: if the host refuses the credential your helper supplied, the environment variables are tried next, and only then the request is left unauthenticated. Each credential is offered once per clone or fetch — a refused one is never replayed.

A credential the host actually refuses is also reported back to your helper with git credential reject, exactly as git itself does, so Git Credential Manager, osxkeychain, libsecret and friends erase their stored copy and resolve afresh next time instead of serving the same dead token on every run. That only happens on an outright rejection of the credential (HTTP 401); a "not found" answer never erases anything, since a repository your credential simply can't see is at least as likely an explanation as a bad credential. Rotating a token therefore takes effect on the next resolution — there's no need to restart the AppHost to clear a cached one.

Credentials are never read from servicesources.yaml (committed) or servicesources.local.json — there's no field for them in either file, by design, so a secret can't accidentally end up in the committed catalog. The one way to get one in there anyway is to embed it in the repository URL itself (https://user:token@host/org/repo); git accepts that form, but it commits the token along with the catalog, so use one of the two mechanisms above instead. Should such a URL be configured regardless, every message this tool prints strips the userinfo from it first, so the token doesn't spread from the catalog into your console and logs.

A clone or fetch that fails for what looks like an authentication reason raises an error naming the service, the repository, and authentication as the likely cause, rather than a generic "failed to clone" message. This includes a "not found" response: GitHub, GitLab and Azure DevOps all answer an unauthenticated request for a private repository with 404 rather than 401, so as not to leak whether it exists, so the error covers both readings — bad credentials, or a repository the credentials in use can't see. A rate-limited response is deliberately left out, even though hosts answer it with the same 403 as a token that's missing a scope: there the credential is fine and the fix is to wait, so it's reported as the transport failure it is.

SSH is not supported. LibGit2Sharp's bundled native binaries don't include an SSH transport, so a repository written as git@host:org/repo, host:org/repo or ssh://... fails fast at resolution time with a message pointing at the HTTPS equivalent — use https://host/org/repo instead. The same check covers an existing checkout whose origin is an SSH remote, before any fetch is attempted against it.

"kubernetes" source

Point a service at an already-running instance in a Kubernetes dev cluster via kubectl port-forward, instead of running it locally at all.

servicesources.yaml:

services:
  orders:
    kubernetes:
      service: orders-svc
      port: 8080

servicesources.local.json:

{
  "services": {
    "orders": {
      "source": "kubernetes",
      "context": "dev-west",
      "namespace": "orders",
      "port": 8080
    }
  }
}

Requires kubectl on PATH, authenticated against the named context.

"url" source

Point a service at a fixed, already-known URL — e.g. a Kubernetes ingress, a staging deployment, or any other reachable HTTP(S) endpoint. There's no underlying resource for Aspire to run; the endpoint resolves straight to the configured URL.

Two consequences follow from the service running out of band: the AppHost's Configure calls are skipped and logged, and a container can't WithReference it — a project or executable can — which fails with a clear error rather than a DCP stack trace. See #58.

servicesources.yaml:

services:
  orders:
    url:
      url: https://orders.example.com

servicesources.local.json:

{
  "services": {
    "orders": { "source": "url" }
  }
}

Set url in the developer config instead to override the catalog's URL for just that developer (e.g. pointing at a personal tunnel or local proxy):

{
  "services": {
    "orders": { "source": "url", "url": "https://orders.dev.internal" }
  }
}

"container" source

Run a published container image locally via Aspire's own container-runtime integration — image pull and lifecycle are managed entirely by Aspire.

servicesources.yaml:

services:
  orders:
    container:
      image: ghcr.io/company/orders
      port: 8080
      defaultTag: latest

servicesources.local.json:

{
  "services": {
    "orders": { "source": "container" }
  }
}

Set tag in the developer config to override the catalog's defaultTag for just that developer:

{
  "services": {
    "orders": { "source": "container", "tag": "v1.4.2" }
  }
}

Combining sources on one catalog entry

A single servicesources.yaml entry can carry blocks for every source at once — the catalog just describes how each source would resolve the service; each developer's servicesources.local.json picks which one actually applies to them:

services:
  orders:
    repository: https://github.com/example/orders
    project: src/Orders.Api/Orders.Api.csproj
    kubernetes:
      service: orders-svc
      port: 8080
    url:
      url: https://orders.example.com
    container:
      image: ghcr.io/example/orders
      port: 8080
      defaultTag: latest

A developer editing the service picks "local"; one debugging against a shared dev cluster picks "kubernetes"; one who just needs it reachable picks "url" or "container" — same catalog entry, same AddService("orders") call in the AppHost, no code changes either way. Each developer's own servicesources.local.json just names which source applies to them — editing orders locally:

{ "services": { "orders": { "source": "local" } } }

debugging against a shared dev cluster:

{ "services": { "orders": { "source": "kubernetes", "context": "dev-west", "namespace": "orders", "port": 8080 } } }

or just needing it reachable, not caring how:

{ "services": { "orders": { "source": "url" } } }

Configuring a resolved service

AddService() returns a builder over the real resource Aspire runs, so the AppHost can inject its own configuration — connection strings, generated secrets, a sibling's endpoint, wait ordering. Values like these come from the AppHost's own graph and can't be written into servicesources.yaml/servicesources.local.json.

The resolved resource's type depends on the source, which each developer chooses, so name the capability you need and it is checked at composition time:

var backend = builder.AddService("backend")
    .Configure<IResourceWithEnvironment>(r => r
        .WithReference(planningDb)
        .WithEnvironment("DBPASSWORD", postgres.Resource.PasswordParameter)
        .WithEnvironment("ENCRYPTIONKEY", builder.AddParameter("EncryptionKey", new GenerateParameterDefault(), secret: true))
        .WithEnvironment("Services__CommonAuth", commonAuth.GetEndpoint("https")))
    .Configure<IResourceWithWaitSupport>(r => r.WaitForCompletion(migrationService));

As<T>() is the same cast without the callback, and reaches anything Configure would — including a satellite kind's own extension methods:

backend.As<JavaScriptAppResource>().WithRunScript("dev");

Configure is skipped for the "url" and "kubernetes" sources, and the skip is logged at startup. Both resolve to something already running elsewhere — a "url" service has no local process at all, and a "kubernetes" service is a kubectl port-forward in front of a remote one, so environment variables applied here would configure kubectl rather than the service. Those services are expected to be configured wherever they actually run.

The one exception is wait ordering on a "kubernetes" service, which still applies: Configure<IResourceWithWaitSupport> (and WaitForService / WaitForServiceCompletion) reach a real, registered kubectl port-forward executable, and holding that back until a migration finishes is exactly what the AppHost asked for. Only configuration that would land on the wrong process is dropped. A "url" service skips wait ordering too, since it has no registered resource for Aspire to hold back.

Skipping rather than failing is deliberate: a developer switching a service to a remote source in their own servicesources.local.json must not break a Program.cs they don't own. You'll see:

warn: Aspire.Hosting.ServiceSources
      Service 'backend': skipped Configure<IResourceWithEnvironment> because its source is
      'kubernetes' — it resolves to a 'kubectl port-forward' in front of an already-running
      service, so the configuration would reach kubectl rather than the service. ...

As<T>() throws for those sources instead of skipping — it has to return a builder, and handing back the kubectl executable would silently configure the wrong process. Prefer Configure for anything that should survive a source switch. It follows the same wait-ordering exception: As<IResourceWithWaitSupport>() on a "kubernetes" service returns the port-forward's builder rather than throwing.

From a guest-language AppHost

Requires Aspire CLI 13.6.0 or newer, which is not released yet. Everything below registers correctly on earlier CLIs, but the TypeScript SDK the CLI generates from it does not compile on them - see the compatibility note under the sample for what fails and why.

Configure<T> is generic, and Aspire's Type System projects a generic method with its type parameter erased — so guest languages get a set of non-generic equivalents instead, one per shape (overloads don't survive codegen either):

const payments = await builder
  .addService('payments')
  .withServiceEnvironment('DEMO_INJECTED_BY_APPHOST', 'true')
  .withServiceReference(inventory);
TypeScript C# equivalent
withServiceEnvironment(name, value) .Configure<IResourceWithEnvironment>(r => r.WithEnvironment(name, value))
withServiceEnvironmentFromParameter(name, parameter) …WithEnvironment(name, parameter)
withServiceEnvironmentFromEndpoint(name, endpoint) …WithEnvironment(name, endpoint)
withServiceReference(other) …WithReference(other)
withServiceConnectionString(source) …WithReference(source)
waitForService(dependency) .Configure<IResourceWithWaitSupport>(r => r.WaitFor(dependency))
waitForServiceCompletion(dependency, { exitCode }) …WaitForCompletion(dependency, exitCode)
withServiceArg(arg) .Configure<IResourceWithArgs>(r => r.WithArgs(arg))

They delegate to Configure<T>, so out-of-band sources are skipped and logged exactly as above — including the wait-ordering exception, which waitForService and waitForServiceCompletion inherit. In C# they're hidden from IntelliSense — use Configure<T>, which reaches every Aspire extension method rather than just these.

Sample

samples/DemoAppHost is a minimal working AppHost demonstrating all three easily-runnable sources: orders via a real managed "local" git checkout (a small project cloned from dotnet/aspire-samples), inventory via the "url" source (pointing at httpbin.org, a live public test API), and payments via the "container" source (the nginxdemos/hello hello-world image) — run it to see the whole flow end to end. ("kubernetes" isn't demoed here since it needs a real cluster and kubectl; see its section above.)

It also carries a catalog service showing kind: java — a "local" checkout of Spring PetClinic run with its own Maven wrapper. builder.UseJava() is wired up, but AddService("catalog") is commented out and the service is left out of servicesources.local.json.example, since unlike the three above it needs a JDK. To run it, do both: uncomment the call and add "catalog": { "source": "local" } to your servicesources.local.json. Leaving it out of that file by default is what keeps the sample from cloning PetClinic on every run — the first AddService prefetches every "local" entry there, whether or not you add it.

cd samples/DemoAppHost
cp servicesources.local.json.example servicesources.local.json
aspire run

A TypeScript AppHost equivalent — proving AddService() is correctly exported and registers with Aspire's Type System from a guest language, and that a resolved service can be configured from TypeScript — lives in samples/DemoAppHostTypeScript. Both of its services use the "container" source so that payments can withServiceReference(inventory): a "url" service runs out of band, and a container consumer of one is rejected up front. A third resource, the probe executable, hands the same inventory handle to Aspire's own withReference() and prints the services__inventory__http__0 variable that injects — so it shows as Exited, not Running, and that single log line is where you see the native service-discovery path working. (Note: this sample requires Aspire CLI 13.6.0 or newer — see the compatibility note below the code block.)

cd samples/DemoAppHostTypeScript
npm install
cp servicesources.local.json.example servicesources.local.json
aspire restore
aspire run

Requires Aspire CLI 13.6.0+: on every CLI released so far - 13.4.6 through 13.5.3 - aspire restore/aspire add correctly registers addService(name: string) in the generated TypeScript SDK (.aspire/modules/aspire.mts) with no diagnostics — confirming the [AspireExport] on AddService works — but the generated SDK fails to compile (TS2552: Cannot find name 'ResourceWithServiceDiscoveryPromise', six errors) because the Aspire CLI's TypeScript codegen didn't emit a *Promise/*PromiseImpl wrapper pair for extension methods returning a bare Aspire interface type (IResourceBuilder<IResourceWithServiceDiscovery>) rather than a concrete resource class.

This was reported as microsoft/aspire#19507 and fixed by microsoft/aspire#19577, which merged to main on 2026-08-22 under the 13.6 milestone. No released CLI carries it, 13.5.3 included.

Verified against a build of that PR (13.6.0-pr.19577.gfa0aea2c): the generated SDK type-checks clean under strict tsc, and the sample runs end-to-end — withReference() on the addService() result injects the resolved service's discovery variables into the consuming resource, e.g. services__inventory__http__0=http://localhost:<port> pointing at the running inventory container. The same sample regenerated with 13.5.1 still reproduces all six TS2552 errors.

Switching between CLI builds can leave a stale code generator under .aspire/, so remove that directory before regenerating: microsoft/aspire#19603.

Status

Early stage, evolving fast. "local", "kubernetes", "url", and "container" sources are all implemented — see docs/superpowers/ for design and implementation history, including the phase 2 backlog (repo auto-update, config discovery walk-up, dependency/infrastructure resolution, and more).

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on KoalaSoft.Aspire.Hosting.ServiceSources:

Package Downloads
KoalaSoft.Aspire.Hosting.ServiceSources.Java

Java support for KoalaSoft.Aspire.Hosting.ServiceSources — lets an AddService() "local" source clone and run a Java service (Maven goal, Gradle task, or a jar) via the .NET Aspire Community Toolkit's Java integration.

KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript

JavaScript support for KoalaSoft.Aspire.Hosting.ServiceSources — runs a "local"-sourced service with kind "javascript" through Aspire.Hosting.JavaScript.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.1 46 9/5/2026
0.4.0 77 9/3/2026
0.3.1 113 8/27/2026
0.3.0 87 8/27/2026
0.2.0 99 8/18/2026
0.1.0 89 8/18/2026

### Breaking

- **Removed the public `ServiceResource` type** (#62). `AddService()` used to return a
 builder over a `ServiceResource` facade that was deliberately never added to
 `builder.Resources`. It now returns a builder over the resource Aspire actually runs — a
 `ProjectResource` for `local`, an Aspire container or executable resource for `container`
 and `kubernetes`, or whatever an `ILocalResourceKind` returns.

 `AddService()`'s declared return type is unchanged,
 `IResourceBuilder<IResourceWithServiceDiscovery>` before and after, so call sites that
 only pass the result to `WithReference(...)` or `GetEndpoint(...)` keep compiling. What
 breaks is code that *names* the type:

 ```csharp
 // No longer compiles - the type is gone:
 ServiceResource resource = builder.AddService("orders").Resource;
 if (builder.AddService("orders").Resource is ServiceResource) { /* ... */ }
 ```

 An assembly compiled against `0.2.0` that references `ServiceResource` throws
 `TypeLoadException` at runtime against this version. Recompile it.

 To configure the resolved service, use the new `Configure<T>()`, or `As<T>()` when you
 need the concrete resource type:

 ```csharp
 builder.AddService("orders")
     .Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb))
     .Configure<IResourceWithWaitSupport>(r => r.WaitForCompletion(migrations));

 IResourceBuilder<ProjectResource> web = builder.AddService("web").As<ProjectResource>();
 ```

 These exist because most of Aspire's configuration extensions cannot bind to
 `IResourceBuilder<IResourceWithServiceDiscovery>` at all, facade or not: `WithEnvironment`
 is constrained to `IResourceWithEnvironment`, which `IResourceWithServiceDiscovery` does
 not extend, so `service.WithEnvironment(...)` has never compiled — before or after — and
 fails with `CS0311`. Reaching that API is what `Configure<T>` is for.

 See **Changed** below for two things that keep compiling and change behavior: calls on the
 returned builder that used to no-op, and the `kubernetes` resource rename.

- **A satellite package now accepts only core versions within its own minor** (#79).
 `KoalaSoft.Aspire.Hosting.ServiceSources.Java` and `.JavaScript` used to declare a floor on
 `KoalaSoft.Aspire.Hosting.ServiceSources`, so NuGet was free to satisfy a satellite with any
 later core, including one whose interfaces had moved:

 ```xml
 <!-- before: any core at or above this version -->
 <dependency id="KoalaSoft.Aspire.Hosting.ServiceSources" version="0.3.0" />
 <!-- after: this minor only -->
 <dependency id="KoalaSoft.Aspire.Hosting.ServiceSources" version="[0.3.0, 0.4.0-0)" />
 ```

 The `-0` on the upper bound keeps the next minor's prereleases out as well as its release:
 `0.4.0-alpha.0.1` sorts *below* `0.4.0`, so a plain `0.4.0` bound would still admit it. That
 matters on the GitHub Packages preview feed, where every package is published as a
 prerelease.

 A satellite implements core's `ILocalResourceKind`, so a mismatched pair failed at run time
 with `MissingMethodException`/`TypeLoadException` rather than at restore. The minor is the
 boundary because that is where a breaking change ships while the version is below `1.0.0`.
 A core patch still resolves, so core can be serviced without republishing every satellite.

 If your AppHost references core and a satellite separately, moving core alone to the next
 minor now fails restore with `NU1107` rather than building and throwing at startup. Move
 both together, or drop the core reference and let the satellite bring core in for you —
 which is what the README's install section now recommends, since one reference has no
 second version to keep in step.

### Added

- `Configure<T>()` and `As<T>()`, for configuring the resource a service resolved to from
 the AppHost that called `AddService()` (#62, fixes #53). `Configure<T>` is skipped
 with a logged warning for the `url` and `kubernetes` sources, which run out of band;
 `As<T>` throws for them.
- A `kind` extension point for the `local` source: `ILocalResourceKind` and
 `AddLocalKind(...)` let a satellite package teach `local` to run a non-.NET service
 (#55, closes #41).
- A `javascript` kind for the `local` source, in the satellite package
 `KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript` (#59, closes #44). After
 `builder.UseJavaScript()`, a `local` service with `kind: javascript` is cloned like any
 other and then run through `Aspire.Hosting.JavaScript`. Its `javascript:` block picks the
 integration — `javascript`, `vite`, `nextjs`, `node` or `bun` — plus the package manager,
 the app directory within the checkout, and the HTTP endpoint a consumer's
 `WithReference(...)` resolves against.
- A `java` kind for the `local` source, in the satellite package
 `KoalaSoft.Aspire.Hosting.ServiceSources.Java` (#60, closes #45). After
 `builder.UseJava()`, a `local` service with `kind: java` runs through the Aspire Community
 Toolkit's Java integration. Its `java:` block selects exactly one run mode — a Maven goal,
 a Gradle task, or a pre-built jar — and `mavenGoal`/`gradleTask` execute the repository's
 own `mvnw`/`gradlew` wrapper, so a JDK is required on the machine but Maven or Gradle
 itself is not.
- Authentication for private git repositories, resolved through the developer's existing
 git credential helpers (#56).
- `AddService()` is exported to Aspire's Type System, so TypeScript AppHosts can call
 `builder.addService('orders')` (#51).
- Port bounds validation (1-65535) for the `kubernetes` source, matching the check the
 `container` source already had (#40, fixes #23).

### Changed

- **The Java satellite accepts older Community Toolkit versions** (#80). Its floor was the
 version it happened to be developed against rather than the oldest carrying the API it
 calls, so consumers already on 13.3.0 were excluded for no reason. `WithMavenGoal`,
 `WithGradleTask` and `WithWrapperPath` all first ship in
 `CommunityToolkit.Aspire.Hosting.Java` **13.3.0**, so the floor moves from 13.4.0 to that.

 Nothing changes for consumers on a newer version: NuGet takes the lowest version that
 satisfies every constraint in the graph, so a floor only decides how far *back* a consumer
 may go. The test suite runs against the floor rather than latest, for the same reason.

 The JavaScript satellite's `Aspire.Hosting.JavaScript` floor was left alone by this change,
 because it is part of the Aspire package family and has to move in step with core's
 `Aspire.Hosting` rather than on its own. Both moved together later in this same release -
 see **The Aspire floor moves from 13.4.6 to 13.5.2** (#112) below.

- **Calls on an `AddService()` result that used to do nothing now take effect** (#62).
 `IResourceWithServiceDiscovery` extends `IResourceWithEndpoints` and `IResource`, so every
 Aspire extension constrained to those already bound to `AddService()`'s return type and
 compiled:

 ```csharp
 IResourceBuilder<IResourceWithServiceDiscovery> service = builder.AddService("orders");

 service.WithHttpEndpoint(targetPort: 1234);   // compiles - before and after
 service.WithExplicitStart();
 service.ExcludeFromManifest();
 service.WithAnnotation(annotation);
 ```

 Against the old facade these silently did nothing, because it was never in
 `builder.Resources`. They now land on the real, registered resource and actually happen.
 Nothing here stops compiling, so an AppHost carrying one of these calls changes behavior on
 upgrade with no diagnostic: re-read any such call on an `AddService()` result as live
 rather than inert.
- **`kubernetes`-sourced resources are renamed from `{service}-portforward` to `{service}`**
 (#62). Aspire keys service discovery off the resource name, so the old name published the
 endpoint as `services__orders-portforward__…` and a consumer resolving `orders` never found
 it. The name is user-visible, though: it is what the dashboard shows, so anything keying off
 it — a `WithReference` by string, saved dashboard state, external log or trace queries
 filtering on resource name — sees a rename.
- Developer-config fields that a service's source does not use — `port` under a `local`
 source, `context` under a `container` source — now fail fast with a
 `ServiceSourcesConfigurationException` naming every offending field, instead of being
 silently ignored (#43, fixes #24).
- `local` services resolve eagerly during `AddService()` rather than at `BeforeStartEvent`,
 because a real resource has to exist by the time `AddService()` returns (#62). Cold
 checkouts still run in parallel — the first `AddService()` starts a prefetch for every
 `local` service in the catalog — so wall-clock is unchanged. Consequences: a checkout
 failure now throws from the failing `AddService()` call rather than being aggregated
 across services, `ILocalResourceKind.Validate` no longer runs before any service has
 touched the app model, and `AddLocalKind(...)` must be called before the first
 `AddService()`.
- Superseded preview packages are pruned from the GitHub Packages feed after each release,
 keeping the five most recent (#68).
- **The Aspire floor moves from 13.4.6 to 13.5.2** (#112, fixes #89). `Aspire.Hosting` and
 `Aspire.Hosting.JavaScript` move together as one matched set — deliberately, because
 `Aspire.Hosting.JavaScript` 13.4.6 is the half of the pair that breaks once `Aspire.Hosting`
 reaches 13.5.x.

 **Move your AppHost's own Aspire version with it.** In an AppHost still on 13.4.x this lifts
 `Aspire.Hosting` to 13.5.2 on its own, while `Aspire.AppHost.Sdk`, `Aspire.Hosting.AppHost`
 and the DCP and dashboard packages the SDK pins to it stay behind — a mixed Aspire family
 that restore reports nothing about. Raise `Aspire.AppHost.Sdk` and
 `Aspire.Hosting.AppHost` to 13.5.2 or later at the same time.

### Fixed

- **The published packages now carry the `polyglot` NuGet tag** (#112). It is what `aspire
 add` reads to surface a package to non-C# AppHosts — the audience the Aspire Type System
 exports elsewhere in this release exist for. Aspire's own targets append the tag, but
 `obj/*.nuget.g.targets` imports those only in the per-framework inner builds, while `pack`
 generates the nuspec from the cross-targeting outer pass: the tag was appended once per
 inner build, each time to a property no nuspec was generated from, and all three packages
 packed without it. Appended in `Directory.Build.targets` instead, under Aspire's own
 condition so the two cannot both add it.

- **A `kind: javascript` service no longer throws `MethodAccessException` when Aspire resolves
 above the JavaScript integration** (#112, fixes #89). Both Aspire references are floors,
 and NuGet resolves the highest floor in the graph: an AppHost on Aspire 13.5.x pulled
 `Aspire.Hosting` up with it — transitively, through the `Aspire.Hosting.AppHost` reference
 `Aspire.AppHost.Sdk` adds implicitly — while nothing lifted `Aspire.Hosting.JavaScript`
 above the floor this package declared. 13.4.6 of it reaches into two of `Aspire.Hosting`'s
 internal types across a friend-assembly boundary, and 13.5.x removes one and revokes access
 to the other, so the pair restored and compiled clean and then threw the first time a
 `javascript` service resolved.

 Raising the floor settles it, and Aspire closed the coupling from its own side in 13.5.0:
 from that release on, `Aspire.Hosting.JavaScript` references no `Aspire.Hosting` internals at
 all, so a mismatched pair above it is ordinary API drift rather than a guaranteed crash.

 The Java satellite was never affected — `CommunityToolkit.Aspire.Hosting.Java` carries its
 own copy of the helper involved and reaches into no `Aspire.Hosting` internals either.

- A service consumed by a *container* now works for every source except `url` (#62, fixes
 #58). The resource returned by `AddService()` is registered with the app model, so DCP
 creates the Service object a container-to-container reference needs. For `url`, a
 `BeforeStartEvent` pre-flight now reports the unsupported combination clearly instead of
 letting DCP fail with `Host endpoint ... should have an associated DCP Service resource`;
 lifting that limitation is tracked as #72.

### Documentation

- nuget.org version and download badges, and a Preview builds section explaining that the
 GitHub Packages feed needs a `read:packages` token (#67).
- How to run several services out of one repository (#64).
- The install section now says to add a satellite package *instead of* the core package
 rather than alongside it, so an AppHost that uses one carries a single reference and has no
 second version to keep in step (#79).
- The guest-language section and the TypeScript sample now name the Aspire CLI version they
 need, 13.6.0 or newer - the codegen fix they depend on is in no released CLI yet (#57).

Full changelog: https://github.com/flojon/aspire-servicesources/blob/main/CHANGELOG.md