15.4 The hub serves the storefront
Three things change in the hub. It registers the storefront's documents, it lets the storefront's origin call it, and it can write its client manifest.
Shopfront.Hub/Program.cs:
using Shopfront;
using Shopfront.Auth;
using Shopfront.Data;
using Shopfront.Extensions;
using Shopfront.Staff;
using Shopfront.Suppliers;
using Shopfront.Trains.LowStockCheck;
using Shopfront.Trains.Restock;
using Shopfront.Trains.StockReport;
using Trax.Api.Auth.Jwt;
using Trax.Api.Dashboard.Extensions;
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
using Trax.Effect.Configuration.ClientExport;
using Trax.Effect.Data.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Data.Services.DomainContext;
using Trax.Effect.Enums;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Logging.Extensions;
using Trax.Effect.JunctionProvider.Progress.Extensions;
using Trax.Effect.Models.Manifest;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.StateMachine.Persistence;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
using Trax.Scheduler.Services.JobSubmitter;
using Trax.Scheduler.Services.Scheduling;
var builder = WebApplication.CreateBuilder(args);
var connectionString =
builder.Configuration.GetConnectionString("Shopfront")
?? throw new InvalidOperationException("ConnectionStrings:Shopfront is not set.");
var development = builder.Environment.IsDevelopment();
builder.Services.AddShopfront(connectionString);
if (development)
builder.Services.AddTraxJwtAuth(jwt =>
jwt.UseSymmetricKey(DevJwt.Issuer, DevJwt.Audience, DevJwt.Key)
);
else
builder.Services.AddTraxJwtAuth(
builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not set."),
builder.Configuration["Auth:Audience"]
?? throw new InvalidOperationException("Auth:Audience is not set.")
);
builder.Services.AddAuthorization();
// The storefront is served from another origin, which the browser asks the hub to allow.
builder.Services.AddCors(cors =>
cors.AddDefaultPolicy(policy =>
policy
.WithOrigins(
builder.Configuration["Storefront:Origin"]
?? throw new InvalidOperationException("Storefront:Origin is not set.")
)
.AllowAnyHeader()
.AllowAnyMethod()
)
);
builder.Services.AddScoped<ISnapshotPrincipal, TraxCallerSnapshotPrincipal>();
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(connectionString)
.SaveTrainParameters()
.AddJunctionLogger(serializeJunctionData: true)
.AddJunctionProgress()
.AddDecisionRecording()
.AddJunctionEvents()
.AddLifecycleHook<StaffFailureHook>()
)
.AddStateMachines(typeof(ManifestNames).Assembly)
.AddMediator(typeof(ManifestNames).Assembly)
.AddScheduler(scheduler =>
{
// The hub hands every job to the worker through Postgres and runs none itself.
scheduler.OverrideSubmitter(services =>
services.AddScoped<IJobSubmitter, PostgresJobSubmitter>()
);
// In Development, retries come seconds apart, and a run whose worker died is given up
// on after a minute rather than an hour, to be watched.
if (development)
scheduler
.DefaultRetryDelay(TimeSpan.FromSeconds(5))
.MaxRetryDelay(TimeSpan.FromSeconds(30))
.DefaultJobTimeout(TimeSpan.FromSeconds(30))
.StaleInProgressTimeout(TimeSpan.FromMinutes(1));
return scheduler
.Schedule<IStockReportTrain>(
ManifestNames.StockReport,
new StockReportInput(),
Cron.Daily(hour: 2),
options =>
options
// The shop's holidays: no count, and none the morning after.
.Exclude(
Exclude.Dates(new DateOnly(2026, 12, 25), new DateOnly(2027, 1, 1))
)
.OnMisfire(MisfirePolicy.DoNothing)
.Variance(TimeSpan.FromMinutes(10))
)
.ThenInclude<ILowStockCheckTrain>(
ManifestNames.LowStockCheck,
new LowStockCheckInput { Threshold = 3 }
)
.Schedule<IRestockTrain>(
ManifestNames.Restock,
new RestockInput { Threshold = 3 },
development ? Every.Minutes(1) : Every.Hours(1),
options => options.MaxRetries(2)
);
})
);
builder.Services.AddTraxGraphQL(graphql =>
graphql
.AddDbContext<ShopfrontDbContext>()
.ExposeOperationQueries()
.ExposeOperationMutations()
.GateOperations(roles: Roles.Staff)
// Shadow mode: everything runs, and what would be refused is logged.
.UsePersistedOperations(persisted =>
persisted
.SingleNode()
.RequirePersisted(false)
.LogNonPersistedRequests(true)
// The storefront's documents, which trax client writes and the build copies here.
.ClientDocuments(Path.Combine(AppContext.BaseDirectory, "trax.documents.json"))
)
);
var app = builder.Build();
// An export of the client manifest opens no database.
if (!TraxClientExport.Requested)
await app.Services.EnsureSchemaCreatedAsync<ShopfrontDbContext>();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();
app.UseTraxApiDashboard(dashboard => dashboard.AllowAnonymousShell());
if (development)
{
app.MapGet(
"/dev/token/{user}",
(string user, bool staff = false) =>
DevJwt.Mint(user, staff ? Roles.Staff : Roles.Customer)
)
.AllowAnonymous();
// Takes this process's fake supplier down, or brings it back.
app.MapPost(
"/dev/supplier/{state}",
(string state, FakeSupplier supplier) =>
{
supplier.IsDown = state == "down";
return Results.NoContent();
}
)
.AllowAnonymous();
}
// Serves, or with --trax-export-client writes the client manifest and exits.
await app.RunTraxAsync(args);What changed:
ClientDocuments(...)registers the documents intrax.documents.jsonat start. The file is read from beside the hub's build, where the project copies it.AddCorsandUseCors(). The page is served fromhttp://localhost:5173and calls the hub onhttp://localhost:5080, so the browser asks the hub first whether that origin may call it. The origin is configuration, so a deployed storefront names its own. The socket checks the same list: a WebSocket from an origin the policy does not name is refused.RunTraxAsync(args)in place ofRunAsync(). Started with--trax-export-client <path>, the hub runs its startup checks, writes the manifest to the path and exits. Otherwise it serves, as before.TraxClientExport.Requestedsays whether this start is an export. An export opens no database, so the schema is not created during one.
Shopfront.Hub/Shopfront.Hub.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Shopfront</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- The host tests start the hub. -->
<InternalsVisibleTo Include="Shopfront.Tests" />
</ItemGroup>
<ItemGroup>
<!-- The storefront's documents, registered at start. trax client writes the file. -->
<None
Include="..\web\src\trax\trax.documents.json"
Link="trax.documents.json"
CopyToOutputDirectory="PreserveNewest"
Condition="Exists('..\web\src\trax\trax.documents.json')"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shopfront.Core\Shopfront.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Trax.Api.Auth.Jwt" />
<PackageReference Include="Trax.Api.Dashboard" />
<PackageReference Include="Trax.Api.GraphQL" />
<PackageReference Include="Trax.Api.GraphQL.PersistedOperations" />
<PackageReference Include="Trax.Effect.JunctionProvider.Logging" />
<PackageReference Include="Trax.Effect.JunctionProvider.Progress" />
<PackageReference Include="Trax.Effect.Provider.Parameter" />
<PackageReference Include="Trax.Scheduler" />
</ItemGroup>
</Project>Shopfront.Hub/appsettings.json:
{
"ConnectionStrings": {
"Shopfront": "Host=localhost;Database=shopfront;Username=postgres;Password=shopfront"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Trax.Effect.JunctionProvider.Logging.Services.JunctionLoggerProvider.JunctionLoggerProvider": "Debug"
}
},
"Storefront": {
"Origin": "http://localhost:5173"
},
"AllowedHosts": "*"
}SDK Reference
Generated clients | trax client | Client manifest | Fetch only what a component needs | Run progress
Next: 15.5 Generate the client