Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/run-main-front-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@btravstack/core": minor
---

**Breaking:** `runMain` now takes the module and options directly —
`runMain(AppModule, { runtime })` — booting `start` itself and carrying the
same compile-time needs gate. The old app-taking form is gone: a whole
`main.ts` is one call, and `start` remains the API for callers that want the
`RunningApp` itself (tests, embedders, a dev runner booting two applications —
none of which may claim `process.exitCode`).

The nesting it replaces — `runMain(start(module, options))` — made `start`
look complete on its own, and using it alone in an entry point is the
documented footgun: the kernel's uncaught handlers suppress Node's default
exit 1, so a crash exited `0`. The front door is now the one-call shape the
docs lead with.

Also exports `RuntimeNeedsGate`, the phantom rest-tuple gate `start`,
`runMain` and `withApp` all carry, previously inlined at each site.
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,11 @@ so a production bundle never pulls the fakes in.
events as `{"cause":{}}`. A cause it cannot serialise at all (a circular
object) falls back to `"[unserialisable]"` rather than throwing, since
`safeSink` would swallow the throw and the event would be reported nowhere.
- **`runMain(app, exit?)`** — awaits `exited` and sets the exit code:
- **`runMain(module, options, exit?)`** — the front door: `start` composed
with the wait for `exited`, carrying the same phantom needs gate
(`RuntimeNeedsGate`, the shared alias all three gated surfaces use). Every
`main.ts` calls this one function; `start` is for callers that want the
`RunningApp` itself. It boots the module and sets the exit code:
`0` clean, `1` a modeled startup `Err`, `2` drained with work abandoned **or
exited with a non-empty `teardownErrors`**, `70` an uncaught
exception/rejection, `70` a defect. Both `70`s are sysexits(3)'s
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,18 @@ const ticker: Runtime<typeof Greeter> = {
},
};

await runMain(start(AppModule, { runtime: ticker }));
await runMain(AppModule, { runtime: ticker });
```

`start` returns immediately with a `RunningApp`; `runMain` awaits its
`exited` and turns the outcome into a process exit code. The runtime's declared
`runMain` is the front door: it boots the module, awaits the application's
exit and turns the outcome into a process exit code — one call, and the whole
of a `main.ts`. Underneath it is `start`, which returns immediately with a
`RunningApp` and decides nothing about the process: reach for it when the
handle itself is wanted (a test, an embedder, a dev runner booting two
applications). The runtime's declared
`needs` are checked against the module's exports **at compile time** — booting
`ticker` against a module that does not export `Greeter` is a type error at the
`start` call, not a boot-time crash.
call, not a boot-time crash.

Every code sample on this page is compiled by
[`packages/core/src/docs-examples.test-d.ts`](./packages/core/src/docs-examples.test-d.ts),
Expand Down
16 changes: 7 additions & 9 deletions examples/order-amqp-worker/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { runMain, start } from "@btravstack/core";
import { runMain } from "@btravstack/core";
import { P } from "unthrown";

import { orderAmqpRuntime } from "./amqp-runtime.js";
Expand All @@ -17,15 +17,13 @@ import { OrderAmqpModule } from "./module.js";
* source-only, and every spec drives `start` directly.
*/
const work = (env: Env): Promise<void> =>
runMain(
start(OrderAmqpModule, {
runtime: orderAmqpRuntime({
urls: [env.AMQP_URL],
relay: { pollMs: env.OUTBOX_POLL_MS },
}),
probes: { port: env.PROBE_PORT },
runMain(OrderAmqpModule, {
runtime: orderAmqpRuntime({
urls: [env.AMQP_URL],
relay: { pollMs: env.OUTBOX_POLL_MS },
}),
);
probes: { port: env.PROBE_PORT },
});

/** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */
const abort = (reason: string): void => {
Expand Down
18 changes: 8 additions & 10 deletions examples/order-api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { runMain, start } from "@btravstack/core";
import { runMain } from "@btravstack/core";
import { FindOrder, Logger, PlaceOrder } from "@btravstack/example-order-application";
import { httpRuntime } from "@btravstack/http";
import { P } from "unthrown";
Expand All @@ -20,16 +20,14 @@ import { OrderApiModule } from "./module.js";
* This file is the shape a real entry point takes.
*/
const serve = (env: Env): Promise<void> =>
runMain(
start(OrderApiModule, {
runtime: httpRuntime({
port: env.PORT,
needs: [PlaceOrder, FindOrder, Logger],
handler: apiHandler,
}),
probes: { port: env.PROBE_PORT },
runMain(OrderApiModule, {
runtime: httpRuntime({
port: env.PORT,
needs: [PlaceOrder, FindOrder, Logger],
handler: apiHandler,
}),
);
probes: { port: env.PROBE_PORT },
});

/** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */
const abort = (reason: string): void => {
Expand Down
21 changes: 10 additions & 11 deletions examples/order-temporal-worker/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { runMain, start } from "@btravstack/core";
import { runMain } from "@btravstack/core";
import { orderContract } from "@btravstack/example-order-temporal-contract";
import { workflowsPathFromURL } from "@temporal-contract/worker/worker";
import { NativeConnection } from "@temporalio/worker";
Expand Down Expand Up @@ -38,23 +38,22 @@ const work = (env: Env): AsyncResult<void, never> =>
fromSafePromise(NativeConnection.connect({ address: env.TEMPORAL_ADDRESS })).flatMap(
(connection) =>
fromSafePromise(
runMain(
start(OrderTemporalModule, {
runtime: temporalWorkerRuntime({
contract: orderContract,
connection,
namespace: env.TEMPORAL_NAMESPACE,
workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") },
}),
probes: { port: env.PROBE_PORT },
runMain(OrderTemporalModule, {
runtime: temporalWorkerRuntime({
contract: orderContract,
connection,
namespace: env.TEMPORAL_NAMESPACE,
workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") },
}),
probes: { port: env.PROBE_PORT },
})
// `.finally`, not a `flatTap`: an open `NativeConnection` holds the
// event loop, so a startup that ends in a defect is exactly the path
// that must still close it. `runMain`'s bare `Promise` is the one
// place a native combinator belongs — it is the documented boundary
// where the Result world ends — and `close` never rejects, so the
// exit code `runMain` just set survives.
).finally(() => close(connection)),
.finally(() => close(connection)),
),
);

Expand Down
7 changes: 5 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,15 @@ const ticker: Runtime<typeof Greeter> = {
},
};

await runMain(start(AppModule, { runtime: ticker }));
await runMain(AppModule, { runtime: ticker });
```

`runMain` is the front door — boot the module, await the exit, set the process
exit code, one call. `start` is the same boot returning the `RunningApp`
instead of deciding the process's fate; it is what tests and embedders use.
The runtime's declared `needs` are checked against the module's exports at
compile time: booting `ticker` against a module that does not export `Greeter`
is a type error at the `start` call.
is a type error at the call.

## What you get

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/docs-examples.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const ticker: Runtime<typeof Greeter> = {
},
};

await runMain(start(AppModule, { runtime: ticker }));
await runMain(AppModule, { runtime: ticker });

// ---------------------------------------------------------------------------
// "The Runtime contract" — root README. Asserted equal to the shipped types
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export { runMain } from "./run-main.js";
export { RuntimeStartFailed } from "./runtime.js";
export type { RunUnit, Runtime, RuntimeHost, Serving } from "./runtime.js";
export { start } from "./start.js";
export type { ExitReport, RunningApp, StartOptions, TeardownError } from "./start.js";
export type {
ExitReport,
RunningApp,
RuntimeNeedsGate,
StartOptions,
TeardownError,
} from "./start.js";
export { currentUnit } from "./units.js";
export type { UnitMeta, UnitRecord, UnitRegistry, UnitWork } from "./units.js";
85 changes: 62 additions & 23 deletions packages/core/src/run-main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Module, Port, Provider } from "@btravstack/di";
import { ErrAsync, OkAsync } from "unthrown";
import { Err, ErrAsync, OkAsync } from "unthrown";
import { describe, expect, it, vi } from "vitest";

import { createFakeClock } from "./fake-clock.js";
import { runMain } from "./run-main.js";
import { awaitExit, runMain } from "./run-main.js";
import { start, type ExitReport } from "./start.js";
import { testRuntime } from "./test-runtime.js";

Expand All @@ -14,9 +14,10 @@ const clean: ExitReport = {
uptimeMs: 1,
};

// The kernel's own machinery is irrelevant here: `runMain` reads exactly one
// thing off a `RunningApp`, so a stub carrying only `exited` is the honest
// fixture.
// The kernel's own machinery is irrelevant to the code table: `awaitExit`
// reads exactly one thing off a `RunningApp`, so a stub carrying only
// `exited` is the honest fixture. The public `runMain` — which boots the
// kernel for real — is driven at the end of the suite.
const appWith = (exited: unknown) => ({ exited, stop: () => {}, phase: () => "exited" }) as never;

class Greeting extends Port("Greeting")<{ readonly text: string }> {}
Expand All @@ -26,19 +27,34 @@ const AppModule = Module("App")({
exports: [Greeting],
});

const FailingModule = Module("Failing")({
provides: [Provider(Greeting)({ make: () => Err("no-config" as const) })],
exports: [Greeting],
});

// `runMain` boots for real, so every call needs the harness options a spec
// always passes to `start` — fresh each time, since a `testRuntime` is
// stateful across starts.
const quiet = () => ({
runtime: testRuntime(),
signals: false as const,
probes: false as const,
onEvent: () => {},
});

describe("runMain", () => {
it("exits 0 on a clean report", async () => {
const codes: number[] = [];

await runMain(appWith(OkAsync(clean)), (code) => codes.push(code));
await awaitExit(appWith(OkAsync(clean)), (code) => codes.push(code));

expect(codes).toEqual([0]);
});

it("exits 0 when the drain finished with nothing abandoned", async () => {
const codes: number[] = [];

await runMain(
await awaitExit(
appWith(
OkAsync({
...clean,
Expand All @@ -54,7 +70,7 @@ describe("runMain", () => {
it("exits 2 when work was abandoned", async () => {
const codes: number[] = [];

await runMain(
await awaitExit(
appWith(
OkAsync({
...clean,
Expand All @@ -73,7 +89,7 @@ describe("runMain", () => {
const codes: number[] = [];

// WHEN the outcome is turned into an exit code
await runMain(
await awaitExit(
appWith(
OkAsync({
...clean,
Expand All @@ -96,7 +112,7 @@ describe("runMain", () => {
// Installing an `uncaughtException` handler suppresses Node's own default
// exit code of 1, so without this row a crashed process would report
// success to its orchestrator.
await runMain(appWith(OkAsync({ ...clean, reason: "uncaught" })), (code) => codes.push(code));
await awaitExit(appWith(OkAsync({ ...clean, reason: "uncaught" })), (code) => codes.push(code));

expect(codes).toEqual([70]);
});
Expand All @@ -107,7 +123,7 @@ describe("runMain", () => {
// The uncaught path skips the drain, so a report carrying both is not
// reachable today — the precedence is asserted so it stays deliberate
// rather than an accident of the order the conditions happen to be in.
await runMain(
await awaitExit(
appWith(
OkAsync({
...clean,
Expand All @@ -124,15 +140,15 @@ describe("runMain", () => {
it("exits 1 on a startup failure", async () => {
const codes: number[] = [];

await runMain(appWith(ErrAsync("no-config")), (code) => codes.push(code));
await awaitExit(appWith(ErrAsync("no-config")), (code) => codes.push(code));

expect(codes).toEqual([1]);
});

it("exits 70 on a defect", async () => {
const codes: number[] = [];

await runMain(
await awaitExit(
appWith(
OkAsync(clean).map(() => {
// oxlint-disable-next-line unthrown/no-throw -- a `Defect` has no public constructor by design, so a throw caught by a combinator's throw-to-defect net is the only way to hand `runMain` the defect this row asserts
Expand All @@ -145,30 +161,53 @@ describe("runMain", () => {
expect(codes).toEqual([70]);
});

// The rows above pin the code table through `awaitExit`; the rest drive the
// public `runMain`, which boots the kernel itself. A module whose provider
// fails is the cheapest deterministic outcome: `start`'s build stops there,
// the runtime never starts, and `exited` settles without a clock or a
// signal in sight.
it("boots the module it is given and maps its startup failure to 1", async () => {
// GIVEN a module whose only provider fails to construct
const codes: number[] = [];

// WHEN the process is run through the front door
await runMain(FailingModule, quiet(), (code) => codes.push(code));

// THEN the modeled Err came back out as the startup exit code — proof the
// module and options actually reached `start`
expect(codes).toEqual([1]);
});

it("sets process.exitCode when no exit callback is supplied", async () => {
// GIVEN the default exit sink
const previous = process.exitCode;

await runMain(appWith(OkAsync(clean)));
// WHEN runMain is called without one
await runMain(FailingModule, quiet());

expect(process.exitCode).toBe(0);
// THEN the code landed on process.exitCode itself
expect(process.exitCode).toBe(1);
process.exitCode = previous;
});

it("never calls process.exit", async () => {
const codes: number[] = [];
// GIVEN a spy that would catch the one call this package must never make
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);

await runMain(appWith(OkAsync(clean)), (code) => codes.push(code));
// WHEN a whole boot-and-exit cycle runs
await runMain(FailingModule, quiet(), () => {});

// THEN the fate was decided through the exit sink alone
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});

// Every case above feeds `runMain` a hand-built report. This one drives a
// real application all the way to an abandoned-work drain, so the `2` is
// produced by the kernel rather than asserted against a fixture — the whole
// chain, from a unit left open at the deadline through `DrainReport` and
// `ExitReport` to the exit code.
// This one drives a real application all the way to an abandoned-work
// drain, so the `2` is produced by the kernel rather than asserted against
// a fixture — the whole chain, from a unit left open at the deadline
// through `DrainReport` and `ExitReport` to the exit code. It holds the
// `RunningApp` to drive the drain, which is exactly the case `start` +
// `awaitExit` exist for.
it("yields 2 from a real application whose drain abandoned work", async () => {
const codes: number[] = [];
const clock = createFakeClock();
Expand All @@ -188,7 +227,7 @@ describe("runMain", () => {
await clock.advance(5_000);
await clock.advance(20_000);

await runMain(app, (code) => codes.push(code));
await awaitExit(app, (code) => codes.push(code));

expect(await app.exited).toBeOkWith(
expect.objectContaining({ drain: { inFlightAtStart: 1, completed: 0, abandoned: 1 } }),
Expand Down
Loading
Loading