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.
Run a built application
Section titled “Run a built application”ArcApplication wraps the ArcServer that the application builder creates, and owns a standalone listener when you ask it to:
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.
| Method | Behavior |
|---|---|
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.server | The ArcServer, for direct calls, adapters, and introspection |
A stopped or disposed application cannot start again.
Listener options
Section titled “Listener options”run() and start() accept the same options:
| Option | Default | Effect |
|---|---|---|
port | 3000 | TCP port; 0 picks an ephemeral port |
host | '127.0.0.1' | Interface to bind. The default is loopback, not every interface |
https | None | Node https server options, such as { key, cert } |
pathBase | None | A path prefix removed before Arc dispatch and static-file lookup |
staticFiles | None | Serve a public directory; see Static files |
fallback | None | SPA navigation fallback file inside staticFiles.root |
native | None | A 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.
Host a low-level server
Section titled “Host a low-level server”If you build an ArcServer directly from low-level definitions, runArc(server, options) takes the same options and returns { server, close, shutdown }:
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.
Shut down without dropping work
Section titled “Shut down without dropping work”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.
Bring your own Node server
Section titled “Bring your own Node server”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:
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.
Security defaults
Section titled “Security defaults”- Arc enforces
hosting.maxBodyBytes(1 MiB by default) on raw command andQUERYbodies, including chunked input, and abortscontext.signalwhen the client disconnects. - For a host-authenticated caller, set
nativePrincipal: trueand pass anative(request)callback that returns{ principal }after your host has verified the request. The callback applies to HTTP and WebSocket requests. Never derive the principal orauthorityfrom request or forwarded headers. See Native principal. - Cookie security is taken from the actual TLS socket, not from headers.
Related
Section titled “Related”- Build an application
- Endpoint mapping
- Static files and SPA fallback
- Host adapters if your application already uses a web framework