Table of Contents

Program and Hosting

Program.cs is the entire host of the HiNC web service: one class whose Main builds an ASP.NET Core application and runs it. It presents no screen of its own — it is the process that starts and stops HiAPI, registers every service the controllers and hubs resolve, maps those controllers and the eight SignalR hubs, serves the built Quasar SPA out of the web root, and falls back to the SPA shell for every URL the Vue router owns. BuildApp is public and returns the built application without starting it, so the build phase and the run phase are separable; Main calls it, writes one startup line to the log, and runs.

HiAPI Lifetime

The host brackets its whole life in one pair of HiAPI calls.

AppBegin(API) runs once during BuildApp, after the service container is built and before the request pipeline is configured. It logs in every licence License holds, initialises the display engine (DispEngine), and opens the SQLite step-cache database at the path it is given, publishing it as SqliteStepStorage's default instance. The overload the webservice calls takes a logger and a cache-database path.

AppEnd(API) unwinds the same set in reverse: it waits for queued background CubeTree frees to drain so no native delete runs against a half-torn-down runtime, disposes the step and identity storages, shuts the display engine down, and logs the licences out.

Three separate events can reach AppEnd, and a static latch guarantees it runs at most once:

  • IHostApplicationLifetime.ApplicationStopping — the ordinary graceful stop.
  • AppDomain.CurrentDomain.ProcessExit — which still fires when a console window is closed outright.
  • Console.CancelKeyPress — Ctrl+C and Ctrl+Break, which cancel the immediate termination and ask the lifetime for a graceful stop instead, so the first path runs.

The cache-database path is Cache/{CacheDbId}.db beneath the admin directory. CacheDbId is the HiNC:CacheDbId configuration value when one is set and the hosting environment name otherwise. Supplying a path matters: SqliteStepStorage given none falls back to a single per-user file, so several instances on one account would otherwise share it. Two instances launched under different --environment names get different cache files with no further configuration.

Startup Order

Three registrations run before the builder exists, because each seeds a table that is read — or frozen — the moment anything else touches it:

  • LocalProjectService.Reg() fills XFactory's default generator table with every type the simulation pipeline may deserialise, which is what every project XML read resolves against.
  • UserConfig.Reg() adds the per-user preference file's own type to the same table; without it, a persisted preference file cannot be read back and every UserService resolution fails once one exists.
  • Lang is appended to StringLocalizer's extended type list, so session-command titles resolve through that assembly's satellite resources. The type list is static, but each localizer builds its own resource-manager list lazily on its first lookup and keeps it, so a type added after a given localizer has been used never reaches that localizer. Registering before the builder exists is what puts the addition ahead of every first lookup.

After the container is built and before the pipeline is assembled, the host wires the native core's log output into the application logger through CppLogUtil, sets the cache identifier, calls AppBegin, and then runs Seed(API) over the admin Resource root. The seeder copies in only the marked .default items, leaves unmarked items alone as user property, and skips the whole pass when its version stamp already matches the shipped resource assembly.

Registered Services

Every application service is registered as a singleton. The repository contains no AddScoped and no AddTransient call, so no service this host registers has a per-request lifetime and any state one of them holds is process-wide. Any shorter lifetime in the container is one of the framework's own registrations rather than an application service.

Four registrations are worth reading closely:

  • UserService is built by a factory rather than by type, so that its configuration path is assigned unconditionally — including when no preference file exists yet, which is the only way the first save can create one. When the file does exist, the factory deserialises it through XFactory.
  • ProxyConfig is bound by hand into a plain singleton from the ProxyConfig configuration section. A separate services.Configure<ProxyConfig> call also registers it through the options system, but nothing resolves IOptions<ProxyConfig>; the plain singleton is what ProxyProjectService and the startup code receive. That binding happens once at startup and is not reloaded.
  • ProxyProjectService is registered twice — once under its own type and once as IProjectService — so the container builds one instance per registration. Both hold nothing but the same two injected singletons, LocalProjectService and ProxyConfig, which is where all the state lives.
  • AuthConfig is bound from the Auth section and registered as an instance, so the authentication controller and the pipeline decision below read the same object. See Login and Authentication for what it switches on.

Six singletons are resolved eagerly at the end of BuildApp so they subscribe to their engine events at startup rather than when the first client happens to connect: the CL-strip broadcast service, the four per-sink message broadcast services, and the NC-program registry.

Kestrel is configured with AllowSynchronousIO = true, which permits handlers to write to the response body synchronously.

Controllers

Controllers are added with AddControllers(), with the host's own assembly registered as an explicit application part, and reached by MapControllers() at the end of the pipeline. Every controller in the host assembly is attribute-routed, and every route template begins with api/ — most as api/[controller], the rest as an explicit kebab-case path such as api/mech/machine-tool or api/execution/cl-strip. No controller route sits outside api/, which is what makes the “everything else is the SPA” fallback rule below safe.

One controller is exempt from the login gate: the authentication controller carries [AllowAnonymous] so it stays reachable when the fallback policy locks everything else.

SignalR Hubs

AddSignalR() is called without protocol configuration, and eight hub routes are mapped:

Route Hub type What it carries
/renderingHub RenderingHub server-rendered canvas frames and the input that drives them, one display engine per connection
/shellMessageHub ShellMessageHub session-level routine and lifecycle messages
/ncDiagnosticHub NcDiagnosticHub NC-pipeline diagnostics
/stepDiagnosticHub StepDiagnosticHub step-anchored diagnostics
/ncManipulationDiagnosticHub NcManipulationDiagnosticHub NC-manipulation diagnostics — writeback conversion and optimisation
/executionStatusHub ExecutionStatusHub ExecutionStatusUpdated, SessionCursor and SessionStatusMessage, broadcast to all clients; the client-callable GetExecutionStatus() answers the caller alone, on the same ExecutionStatusUpdated name
/clStripHub ClStripHub strip-chart display range, selected and entered step, and throttled data-update hints
/cleanupHub CleanupHub index keys, and optional follow-up action keys, that a client registers against its connection with Add; the disconnect sweep finds none of them — see below

The four message hubs share one base class and one contract — the client calls GetMessages(limit) and the server answers on MessagesUpdate — but they are deliberately separate routes rather than one hub with a discriminator, so a client subscribes to exactly the sink it wants and an idle panel means an idle hub rather than a lost connection.

The cleanup hub is the one whose registry does not outlive the call that fills it. SignalR builds a fresh hub instance for every invocation, and the key dictionary Add writes to is an instance member rather than shared state, so the dictionary the disconnect handler walks is a different, empty one and releases nothing. Only IndexService — a singleton — is shared across those instances. The client does not depend on the hub for the release: useCleanupHub.ts keeps its own set of registered keys and posts each one to /api/Index/Remove before the component unmounts. That is the path that actually frees an entry.

JSON Serialisation

The controllers' serializer options carry two settings, both applied through AddJsonOptions.

JsonNumberHandling.AllowNamedFloatingPointLiterals. Several engine values use double.PositiveInfinity as their “no limit” state — the optimizer's Max Feed Per Tooth and Preferred Force among them — and the snapshots the SPA reads carry those doubles straight into the response object. System.Text.Json refuses to write an infinite or NaN double as a JSON number, so without this setting every such response would fault during serialisation instead of returning. With it, those values travel as the JSON strings "Infinity", "-Infinity" and "NaN". The reading side is not uniform: the numeric input widgets take all three spellings case-insensitively, while the option-snapshot reader in the mission API recognises the two infinity spellings only and resolves anything else non-numeric — "NaN" included — to the fallback its caller passed, which is positive infinity for Max Feed Per Tooth and Preferred Force. Numeric Input/Output covers the client half of that boundary, and NC Optimization Option Panel (NC Optimization Config) the panel that leans hardest on it — including the two write endpoints that take a string body so the same spelling survives the round trip.

JsonStringEnumConverter. Enums are read and written as their names rather than as integers, which is what the API wrappers expect and what keeps the OpenAPI schema self-describing.

Both settings belong to the MVC controllers only. Hub payloads are serialised by SignalR's own protocol, which is left at its defaults, so neither the named floating-point literals nor the string enums extend to hub messages.

The Request Pipeline

The order the middleware is added in is load-bearing, and the host sets it explicitly rather than accepting the default arrangement:

  1. Forwarded headers first, honouring X-Forwarded-For and X-Forwarded-Proto so that every later stage sees the real client scheme. Both the known-proxy and the known-network lists are cleared, so those headers are accepted from any caller.
  2. Swagger — the Swashbuckle document and its UI, both middleware. MapOpenApi beside them registers the framework's own document as an endpoint instead, so that one is reached at step 8 with everything else that is routed.
  3. Default files, then static files, serving the built SPA and everything else physically present in the web root.
  4. UseRouting() — placed here on purpose. An application that never calls it gets routing inserted at the front of the pipeline instead, which would select the SPA fallback endpoint before the static-file middleware ran; that middleware stands down once an endpoint is chosen, so a directory URL under the web root would be answered by the Vue router's not-found view rather than by the directory's own default document.
  5. CORS, applying the AllowAll policy described below.
  6. HTTPS redirection, skipped in the Development environment.
  7. Authentication, then authorization. Both are no-ops when the login gate is off, because no scheme and no fallback policy are registered in that case.
  8. Endpoints — controllers, the eight hubs, and the two SPA fallbacks.

A request answered before step 7 never reaches the authorization stage at all: the static-file middleware and the Swagger middleware both sit ahead of it and short-circuit the request when they match. The two SPA fallbacks are endpoints rather than middleware, so they do reach it, and both are marked AllowAnonymous — which is what lets the SPA shell, carrying no data of its own, load and show its own login view when the gate is on.

The SPA Fallback

Two fallback registrations serve index.html:

  • util/file-explorer/{**location} — an explicit pattern with no file-name constraint.
  • The bare fallback, for everything else.

The bare form's implicit route pattern carries a nonfile constraint that rejects any URL whose last segment contains a dot. File Explorer deep links mirror real folder and file names into the URL, and those names routinely contain dots, so reloading or pasting such a location needs the explicit pattern. Genuine static assets cannot be shadowed by it, because the static-file middleware has already run by the time either fallback is reached.

Serving the SPA

The Quasar build writes its output directly into the host's web root — the front end's build configuration sets its distribution directory to the web service's wwwroot — so there is no copy step between building the SPA and serving it. The router runs in history mode, which is what makes the SPA fallback necessary: every client-side route is an extension-less URL the server has no endpoint for.

The front-end build script runs the i18n census and glossary lint before invoking the Quasar build, so a build that fails those checks produces no new bundle.

For front-end work the Quasar dev server runs on its own port and proxies the routes the backend owns to the running service. That proxy list holds /api, /swagger, /renderingHub, /executionStatusHub, /clStripHub, /cleanupHub, and /sessionMessageHub — a path the host maps no hub on. The four per-sink message hubs are absent from it, so the Session Messages panels receive no live messages through the dev server.

OpenAPI and Swagger

A single private constant holds the REST contract version. It is simultaneously the Swashbuckle document group name — so it appears in the document's route — and the version the UI displays, which is why it is defined in one place. It is raised by hand on a breaking REST change only, and is deliberately not tied to the assembly version or to the project-file format version.

Both OpenAPI surfaces are exposed in every environment rather than behind a development gate, so an automated caller always has a machine-readable contract. The Swashbuckle UI is served at /swagger and its document at /swagger/{version}/swagger.json; the framework's own document is published under /openapi/.

Schema ids are keyed on the full type name with the nested-type separator normalised. Without that, two controllers' nested request types that share a simple name collide and the document generator throws — which surfaces as an HTTP 500 from the document route rather than as a startup failure. XML documentation comments are folded in from the host assembly and from every referenced assembly whose name starts with Hi. Every read is skipped when its file is missing, and each referenced-assembly read carries a catch-all of its own on top of that, so one whose file is present but unreadable is ignored; the host assembly's read has no such catch-all.

CORS

One policy, named AllowAll, permits any origin, any method and any header, and it is applied globally. It does not allow credentials, so the response carries a wildcard origin and a cross-origin browser call cannot use the session cookie. The SPA is served from the same origin as the API and the hubs, so this constrains only callers hosted elsewhere.

Logging

The default providers are cleared and two are added: a console provider, and a daily file provider rooted at a logs folder beneath the process's current working directory. The file provider writes one line per entry to log-{yyyy-MM-dd}.txt and swallows every I/O failure, on the principle that logging must not fault a request or a startup.

The two providers are filtered independently. The console follows the Logging:LogLevel section of configuration, which sets everything to Warning. The file provider carries its own code-set rules — Information for the application's own categories, Warning for Microsoft and System — and provider-specific rules take precedence over configuration's provider-neutral ones, so the file stays useful while the console stays quiet.

Main writes one Information line immediately after the application is built, so the file for the day the service starts has content before the first request arrives. That guarantee stops at midnight: the provider recomposes the file name from the clock on every append, and creates nothing ahead of time, so a host left running into a new day has no file for it until the next entry is written. GET /api/project/logs reads the same directory.

Configuration

Configuration is the standard chain: appsettings.json, then appsettings.{Environment}.json, then environment variables, then command-line arguments. The keys this host reads are:

Key What it controls
Logging:LogLevel the console provider's levels
AllowedHosts host filtering; shipped as a wildcard
Kestrel:Endpoints the addresses the server binds
ProxyConfig:AdminDirectory the admin working root, under which Resource and Cache live
Auth the optional login gate
HiNC:CacheDbId an explicit step-cache database id, overriding the environment name

The Development overlay repeats the logging, admin-directory and authentication sections and declares no Kestrel section, so the listening address is the same under either environment.

Kestrel Endpoints Win

Kestrel:Endpoints is the authoritative source of the listening address. When it declares endpoints, Kestrel binds those and ignores addresses supplied any other way — ASPNETCORE_URLS, --urls, and the launch profile's applicationUrl alike — logging that it is overriding them. The shipped configuration declares one HTTP endpoint on loopback port 5000. The http launch profile names the same address, so the override is invisible there; the https profile also names an HTTPS address on a second port, and that address is one of the ones Kestrel discards, so launching under it still yields plain HTTP on 5000 and nothing else.

To move a local instance to another port, set the Kestrel__Endpoints__Http__Url environment variable, which the environment-variable provider maps onto the same key. Setting ASPNETCORE_URLS alone changes nothing.

The endpoint binds localhost, so the shipped configuration answers only on the machine it runs on. Reaching it from elsewhere means either overriding that key or placing a reverse proxy in front, which is the arrangement the forwarded-headers configuration handles.

Environment and Working Directory

The environment name reaches further here than usual. Both launch profiles set it to Development, which selects the Development overlay, skips HTTPS redirection, and — unless HiNC:CacheDbId overrides it — names the step-cache database file.

Two paths follow the process's current working directory rather than the content root: the logs folder the file logger writes and the log endpoint reads, and the per-user preference file. An instance launched from the project directory finds both where they are expected; one launched from elsewhere puts them beside wherever it was started.

Source Code Path

See HiNC App Anatomy for git repository links.

Web Application

HiNC-2025-webservice (Quasar CLI SPA):

  • Program.cs — the whole subject of this page: the pre-builder registrations, every service registration, the JSON and Swagger options, the CORS policy, the pipeline order, the eight MapHub calls, the two SPA fallbacks, the AppBegin / AppEnd pair and the three shutdown triggers.
  • appsettings.json — the shipped listening address, the admin directory and the login section.
  • appsettings.Development.json — the Development overlay, which declares no Kestrel section.
  • Properties/launchSettings.json — the two launch profiles whose applicationUrl the Kestrel section overrides.
  • Common/DailyFileLoggerProvider.cs — the per-day file logger the log endpoint reads back.
  • Common/AuthConfig.cs — the shape bound from the Auth section.
  • Common/AuthController.cs — the anonymous carve-out that keeps login reachable under the fallback policy.
  • Common/NamedRootResolver.cs — resolves the admin, project and resource roots the seeder and the file endpoints work against.
  • Common/CleanupHub.cs — the cleanup hub, and the per-invocation dictionary its disconnect handler walks.
  • Common/IndexController.cs — the index remove endpoint the client calls before unmount.
  • Disp/RenderingHub.cs — the canvas hub.
  • Execution/SessionSinkHub.cs — the shared base and the four per-sink message hubs.
  • Execution/ExecutionStatusHub.cs — the status, cursor and session-message hub.
  • Execution/ClStripHub.cs — the strip-chart hub.
  • Environments/UserConfig.cs — the preference type registered before the builder is created.
  • Environments/ProjectController.cs — the log endpoints that read the daily file.
  • wwwroot-src/src/composables/useCleanupHub.ts — the client's own key set and the index-remove calls that release it.
  • wwwroot-src/src/api/mission.ts — the option-snapshot reader that maps the named-literal strings back to numbers, and the string-bodied setters that send them.
  • wwwroot-src/quasar.config.ts — the build output directory that puts the bundle in the web root, the history router mode, and the dev-server proxy list.
  • wwwroot-src/package.json — the build script that runs the i18n lint before the Quasar build.

HiAPI Engine

  • HiNc/HiNcKits/LocalApp.csAppBegin and AppEnd: licence log-in and log-out, display-engine start and finish, and the step-storage open and dispose.
  • HiNc/HiNcKits/HiNcHost.cs — the cache-database identifier the host assigns at startup.
  • HiNc/HiNcKits/ProxyConfig.cs — the admin-directory setting and its own default.
  • HiNc/HiNcKits/ResourceSeeder.cs — the marked-defaults seeding pass and its version stamp.
  • HiNc/SqliteUtils/SqliteStepStorage.cs — the step cache, and the per-user default path that makes an explicit per-instance path necessary.
  • HiNc/MachiningProcs/ProxyProjectService.cs — the project service registered under two service types, holding only the singletons it is given.
  • HiGeom/Common/StringLocalizer.cs — the static extended type list, and the per-instance resource-manager list each localizer builds on its own first lookup.

See Also