Skip to content

Arc.Core and the standalone Node host

Not every service needs a web framework. A worker that exposes a few commands, or a small application that serves its own frontend, would otherwise pull in Express only to listen on a port and shut down cleanly.

@cratis/arc.core is the whole Arc application model: the command and query pipelines, validation, authorization, services, and the result envelope. It also carries a small Node host, so an application can serve its routes, and a built frontend, without Express, Fastify, or Hono. When you do use one of those frameworks, the same application mounts in it unchanged; see Host adapters.

ArcApplication wraps the ArcServer that the application builder creates, and owns a standalone listener when you ask it to:

main.ts
import { ArcApplication } from '@cratis/arc.core';
import { Tasks } from './Features/Tasks/Tasks.js';
import { metadata } from './Features/generatedMetadata.js';
const builder = ArcApplication.createBuilder();
builder.useGeneratedMetadata(metadata);
builder.services.addSingleton(Tasks);
await builder.discover(new URL('./Features/', import.meta.url));
export const app = await builder.build();
await app.run({ port: Number(process.env.PORT ?? 3000) });

This is the Tasks sample entry point. app.run() starts the listener and waits until SIGINT, SIGTERM, or app.stop(), then closes the listener and disposes the application. Its signal handlers are removed when it returns.

MethodBehavior
app.run(options?)Start, then wait for a signal or stop(); closes gracefully
app.start(options?)Start listening and return; the caller decides when to stop
app.stop()Coordinate participant shutdown with listener closure, then dispose services
app.dispose()Same as stop(), whether or not a listener was started
app.serverThe ArcServer, for direct calls, adapters, and introspection

A stopped or disposed application cannot start again.

run() and start() accept the same options:

OptionDefaultEffect
port3000TCP port; 0 picks an ephemeral port
host'127.0.0.1'Interface to bind. The default is loopback, not every interface
httpsNoneNode https server options, such as { key, cert }
pathBaseNoneA path prefix removed before Arc dispatch and static-file lookup
staticFilesNoneServe a public directory; see Static files
fallbackNoneSPA navigation fallback file inside staticFiles.root
nativeNoneA trusted callback returning a host-verified principal or authority for each request

pathBase is matched case-insensitively and must be a segment-delimited path with no trailing slash. Unlike .NET’s UsePathBase, this host also moves static files under the base; introspection route values and the OpenAPI document are not rewritten to include it.

If you build an ArcServer directly from low-level definitions, runArc(server, options) takes the same options and returns { server, close, shutdown }:

server.ts
import { ArcServer, defineCommand, runArc } from '@cratis/arc.core';
import { z } from 'zod';
const echo = defineCommand({
name: 'Echo',
schema: z.object({ value: z.string() }),
handle: ({ value }) => value
});
const arc = new ArcServer({ commands: [echo] });
const host = await runArc(arc, { port: 3000, host: '127.0.0.1' });
process.once('SIGINT', () => {
void host.shutdown();
});

curl -X POST http://127.0.0.1:3000/api/echo -d '{"value":"hello"}' answers with "response":"hello". host.close() closes only the listener; host.shutdown() coordinates listener closure and Arc disposal. If you supplied a caller-owned registry, pass it explicitly as host.shutdown({ registry }). Neither close() nor a plain server.dispose() takes ownership of a caller-owned registry.

Use host.shutdown({ timeoutMs, registry? }) for coordinated shutdown. With participants, it closes Arc admission before initiating listener and transport closure; producer release and transport closure finish before participant stop. Drains finish before delivery work and scopes are disposed. Without participants, it retains the original host-first order. Repeated shutdown joins the first outcome. If you supplied a caller-owned registry, pass that same registry explicitly; Arc never disposes it implicitly. Without it, shutdown closes the listener and performs local server cleanup only, and the registry and its participants stay with their owner. For other hosts, import shutdownArcHost(server, () => closeListener(), registry?) from @cratis/arc.core/hosting and pass the borrowed registry only when its owner authorizes disposal.

close({ timeoutMs }) remains listener-only. It stops accepting HTTP connections, closes Arc WebSockets and ends live server-sent-event streams, then waits up to 30 seconds by default for ordinary requests and WebSockets to drain. At the deadline it closes remaining connections and rejects if shutdown is incomplete. WebSocket cleanup has its own query.observableShutdownTimeoutMs bound in configuration; an earlier host deadline can reject before that cleanup finishes. A producer that ignores cancellation, or whose cleanup waits for a later participant phase, cannot complete coordinated shutdown; Arc does not dispose its live dependencies on a timeout.

When you already own a node:http server, use createArcNodeHandler as its request handler. A request handler cannot see WebSocket upgrades, so attach those explicitly on the same listener:

server.ts
import { createServer } from 'node:http';
import { createArcNodeHandler } from '@cratis/arc.core';
import { attachNodeWebSockets, shutdownArcHost } from '@cratis/arc.core/hosting';
import { app } from './app.js';
const listener = createServer(createArcNodeHandler(app.server));
const closeSockets = attachNodeWebSockets(listener, app.server);
listener.listen(3000, '127.0.0.1');
process.once('SIGTERM', async () => {
await shutdownArcHost(app.server, async () => {
await closeSockets();
await new Promise<void>(resolve => listener.close(() => resolve()));
});
});

Here app is a built ArcApplication from your own module. With a path base, pass it as the fourth attachNodeWebSockets argument; a trusted native resolver goes third. createArcNodeHandler does not manage listener errors, server-sent-event shutdown, or connection draining; you own those on your server. runArc and app.run() already own upgrades on their listener, so do not attach a second bridge there.

  • Arc enforces hosting.maxBodyBytes (1 MiB by default) on raw command and QUERY bodies, including chunked input, and aborts context.signal when the client disconnects.
  • For a host-authenticated caller, set nativePrincipal: true and pass a native(request) callback that returns { principal } after your host has verified the request. The callback applies to HTTP and WebSocket requests. Never derive the principal or authority from request or forwarded headers. See Native principal.
  • Cookie security is taken from the actual TLS socket, not from headers.