create-nwire 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +7 -8
  2. package/dist/index.d.ts +0 -1
  3. package/dist/index.js +0 -1
  4. package/package.json +1 -1
  5. package/templates/enterprise/__tests__/auto-moderate.test.ts +6 -14
  6. package/templates/enterprise/__tests__/submit-flow.test.ts +6 -14
  7. package/templates/enterprise/app/api.ts +11 -18
  8. package/templates/enterprise/app/app.ts +37 -21
  9. package/templates/enterprise/app/main.ts +40 -21
  10. package/templates/enterprise/modules/posts/routes/approve-post.ts +10 -6
  11. package/templates/enterprise/modules/posts/routes/get-post.ts +8 -4
  12. package/templates/enterprise/modules/posts/routes/list-queue.ts +12 -6
  13. package/templates/enterprise/modules/posts/routes/reject-post.ts +8 -4
  14. package/templates/enterprise/modules/posts/routes/submit-post.ts +9 -9
  15. package/templates/enterprise/package.json +7 -6
  16. package/templates/minimal/__tests__/hello.test.ts +19 -15
  17. package/templates/minimal/app/api.ts +11 -14
  18. package/templates/minimal/app/main.ts +26 -15
  19. package/templates/minimal/app/routes/hello.ts +6 -13
  20. package/templates/minimal/package.json +4 -2
  21. package/templates/service/README.md +1 -1
  22. package/templates/service/__tests__/todo-api.test.ts +21 -20
  23. package/templates/service/app/api.ts +11 -20
  24. package/templates/service/app/main.ts +31 -20
  25. package/templates/service/app/middleware/require-user.ts +10 -11
  26. package/templates/service/app/routes/complete-todo.ts +8 -11
  27. package/templates/service/app/routes/create-todo.ts +9 -10
  28. package/templates/service/app/routes/delete-todo.ts +9 -9
  29. package/templates/service/app/routes/list-todos.ts +8 -11
  30. package/templates/service/app/store/todo-store.ts +2 -2
  31. package/templates/service/package.json +7 -5
  32. package/dist/__tests__/scaffold.test.d.ts +0 -7
  33. package/dist/__tests__/scaffold.test.d.ts.map +0 -1
  34. package/dist/__tests__/scaffold.test.js +0 -113
  35. package/dist/__tests__/scaffold.test.js.map +0 -1
  36. package/dist/index.d.ts.map +0 -1
  37. package/dist/index.js.map +0 -1
  38. package/templates/enterprise/modules/posts/posts.module.ts +0 -36
package/README.md CHANGED
@@ -15,11 +15,11 @@ yarn create nwire my-app --template service
15
15
 
16
16
  ## Templates
17
17
 
18
- | Name | What you get |
19
- | ------------ | -------------------------------------------------------------------------------------------------------- |
20
- | `minimal` | One POST route. `httpInterface` + `endpoint`. No app, no DI. ~30 LOC. |
21
- | `service` | Todo CRUD. Container, plugin lifecycle, structured errors, middleware, in-memory store. |
22
- | `enterprise` | Moderation queue. `createApp`, modules, actors, events, stateful workflow (saga), projection, resolvers. |
18
+ | Name | What you get |
19
+ | ------------ | ----------------------------------------------------------------------------------------------------------- |
20
+ | `minimal` | One POST route. `createApp` + `httpKoa`. No DI, no forge. ~40 LOC. |
21
+ | `service` | Todo CRUD. Container plugin, structured errors, route middleware, in-memory store. |
22
+ | `enterprise` | Moderation queue. Forge: actions + events + actors + stateful workflow + projection, multi-app composable. |
23
23
 
24
24
  Pick the smallest template that fits — graduating up is just re-scaffolding
25
25
  into a new folder and porting your code one file at a time.
@@ -29,9 +29,8 @@ deprecated synonyms for `minimal`, `service`, and `enterprise`.
29
29
 
30
30
  ## What ships with every template
31
31
 
32
- - **Zod-validated** route handlers, automatic OpenAPI at `/openapi.json`
33
- - **Graceful shutdown** via `@nwire/endpoint` (SIGTERM drain + lightship probes)
34
- - **K8s probes** on port 9000 (`/readyz`, `/healthz`)
32
+ - **Zod-validated** route handlers, structured error envelopes
33
+ - **Graceful shutdown** via `@nwire/endpoint` (SIGTERM drain + K8s probes)
35
34
  - **TypeScript strict** + Bundler module resolution for fast dev loops
36
35
 
37
36
  ## After scaffold
package/dist/index.d.ts CHANGED
@@ -24,4 +24,3 @@ export declare function scaffold(opts: ScaffoldOptions): {
24
24
  export declare function resolveTemplateName(raw: string): TemplateName | null;
25
25
  export declare function runCli(): Promise<void>;
26
26
  export {};
27
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -184,4 +184,3 @@ const main = defineCommand({
184
184
  export async function runCli() {
185
185
  await runMain(main);
186
186
  }
187
- //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nwire",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Scaffolder for new Nwire projects. Run `pnpm create nwire <name>` or `npm create nwire <name>` to bootstrap.",
5
5
  "keywords": [
6
6
  "nwire",
@@ -10,27 +10,19 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
- import http from "node:http";
14
- import type { Server } from "node:http";
15
- import { app } from "../app/app";
16
- import { api } from "../app/api";
13
+ import { bootstrap } from "../app/main";
17
14
 
18
- let server: Server;
15
+ let running: Awaited<ReturnType<typeof bootstrap>>;
19
16
  let url: string;
20
17
 
21
18
  beforeAll(async () => {
22
- await app.start();
23
- server = http.createServer(api.compile());
24
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
25
- const addr = server.address() as { port: number };
26
- url = `http://127.0.0.1:${addr.port}`;
19
+ running = await bootstrap({ port: 0, test: true });
20
+ url = `http://127.0.0.1:${running.koa.port()}`;
27
21
  });
28
22
 
29
23
  afterAll(async () => {
30
- await new Promise<void>((resolve, reject) =>
31
- server.close((err) => (err ? reject(err) : resolve())),
32
- );
33
- await app.stop();
24
+ await running.running.shutdown("test");
25
+ await running.app.stop();
34
26
  });
35
27
 
36
28
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -4,27 +4,19 @@
4
4
  */
5
5
 
6
6
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
7
- import http from "node:http";
8
- import type { Server } from "node:http";
9
- import { app } from "../app/app";
10
- import { api } from "../app/api";
7
+ import { bootstrap } from "../app/main";
11
8
 
12
- let server: Server;
9
+ let running: Awaited<ReturnType<typeof bootstrap>>;
13
10
  let url: string;
14
11
 
15
12
  beforeAll(async () => {
16
- await app.start();
17
- server = http.createServer(api.compile());
18
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
19
- const addr = server.address() as { port: number };
20
- url = `http://127.0.0.1:${addr.port}`;
13
+ running = await bootstrap({ port: 0, test: true });
14
+ url = `http://127.0.0.1:${running.koa.port()}`;
21
15
  });
22
16
 
23
17
  afterAll(async () => {
24
- await new Promise<void>((resolve, reject) =>
25
- server.close((err) => (err ? reject(err) : resolve())),
26
- );
27
- await app.stop();
18
+ await running.running.shutdown("test");
19
+ await running.app.stop();
28
20
  });
29
21
 
30
22
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -1,28 +1,21 @@
1
1
  /**
2
- * HTTP interface one `.wire(Route, Handler)` per operation.
2
+ * Wiresthe surface this app exposes.
3
3
  *
4
- * The route + handler pairs all live in `modules/posts/routes/`. The
5
- * interface stays a 1-line-per-route reducer.
6
- *
7
- * `.provide(app.runtime.getContainer())` hands the forge runtime's
8
- * container to the http layer so handlers can `resolve()` from any
9
- * binding the runtime registered (e.g. dispatch, query, the runtime
10
- * itself for ad-hoc reads).
4
+ * Every route file under `../modules/posts/routes/` exports a
5
+ * `{name}Route` (binding) + `{name}Handler` (function). The `wires`
6
+ * array is what main.ts feeds into `app.wire(...)`.
11
7
  */
12
8
 
13
- import { httpInterface } from "@nwire/http";
14
- import { app } from "./app";
15
-
16
9
  import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
17
10
  import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/approve-post";
18
11
  import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
19
12
  import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
20
13
  import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
21
14
 
22
- export const api = httpInterface({ prefix: "/api" })
23
- .provide(app.runtime.getContainer())
24
- .wire(submitPostRoute, submitPostHandler)
25
- .wire(approvePostRoute, approvePostHandler)
26
- .wire(rejectPostRoute, rejectPostHandler)
27
- .wire(listQueueRoute, listQueueHandler)
28
- .wire(getPostRoute, getPostHandler);
15
+ export const wires = [
16
+ { binding: submitPostRoute, handler: submitPostHandler },
17
+ { binding: approvePostRoute, handler: approvePostHandler },
18
+ { binding: rejectPostRoute, handler: rejectPostHandler },
19
+ { binding: listQueueRoute, handler: listQueueHandler },
20
+ { binding: getPostRoute, handler: getPostHandler },
21
+ ] as const;
@@ -1,28 +1,44 @@
1
1
  /**
2
- * `createApp` composes the posts module into a runnable app.
2
+ * `buildApp` composes the posts bounded context into a runnable App.
3
3
  *
4
- * Why createApp at L4 (when L2 skipped it):
4
+ * 0.10 shape no modules. Each piece (events, actions, workflows,
5
+ * projections, queries) is imported directly; createApp installs a
6
+ * forge plugin with the workflows/projections/queries it needs, and
7
+ * registers the handlers via the `handlers` array.
5
8
  *
6
- * - We have a workflow that subscribes to events on the bus. The
7
- * workflow needs the runtime to register its subscription and
8
- * fire it on PostWasSubmitted. That's what `createApp` orchestrates.
9
+ * For multi-BC apps, build each as its own App and compose:
9
10
  *
10
- * - We have a projection that folds events into state. Same story —
11
- * the runtime registers the projection's reducers and calls them
12
- * on every matching event.
13
- *
14
- * - We have actions that emit events. The runtime persists those
15
- * events through the implicit bus and routes them to all subscribers.
16
- *
17
- * One module, no plugins yet — but the moment we add a Postgres-backed
18
- * projection store, a Redis cache, OTel tracing, an audit log plugin,
19
- * they all attach here via `.use(plugin)`. The same `createApp` API
20
- * scales from one module to dozens.
11
+ * const monolith = appCompose(postsApp, ordersApp);
12
+ * await endpoint("monolith", { port: 3000 }).use(httpKoa()).mount(monolith).run();
21
13
  */
22
14
 
23
- import { createApp } from "@nwire/forge";
24
- import { postsModule } from "../modules/posts/posts.module";
15
+ import { createApp } from "@nwire/app";
16
+ import { createForgePlugin } from "@nwire/forge";
17
+
18
+ import { submitPost } from "../modules/posts/actions/submit-post";
19
+ import { approvePost } from "../modules/posts/actions/approve-post";
20
+ import { rejectPost } from "../modules/posts/actions/reject-post";
21
+ import { autoModerate } from "../modules/posts/workflows/auto-moderate";
22
+ import {
23
+ queueDashboard,
24
+ listPending,
25
+ queueTotals,
26
+ getPost,
27
+ } from "../modules/posts/projections/queue-dashboard";
28
+ import { postsByAuthor } from "../modules/posts/queries/posts-by-author";
29
+
30
+ export function buildApp() {
31
+ return createApp({
32
+ appName: "{{PROJECT_NAME}}",
33
+ plugins: [
34
+ createForgePlugin({
35
+ workflows: [autoModerate],
36
+ projections: [queueDashboard],
37
+ queries: [listPending, queueTotals, getPost, postsByAuthor],
38
+ }),
39
+ ],
40
+ handlers: [submitPost.handler!, approvePost.handler!, rejectPost.handler!],
41
+ });
42
+ }
25
43
 
26
- export const app = createApp({
27
- modules: [postsModule],
28
- });
44
+ export const app = buildApp();
@@ -1,23 +1,18 @@
1
1
  /**
2
- * Entry — boot the forge app and run the http interface under endpoint().
2
+ * Entry — boot the app under HTTP.
3
3
  *
4
- * The lifecycle:
5
- *
6
- * 1. `app` (./app.ts) is a configured-but-unbooted forge app modules
7
- * registered, workflow subscribers wired, projection reducers
8
- * attached, but nothing's running yet.
9
- * 2. `app.start()` boots the runtime — workflows subscribe to the bus,
10
- * projection state initializes, framework events fire.
11
- * 3. `api.inspect(app)` mounts the `/_nwire/*` introspection surface
12
- * that Studio's Live + Trace pages consume.
13
- * 4. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
4
+ * 1. buildApp registers handlers + forge plugin (workflows, projections,
5
+ * queries) on the container.
6
+ * 2. app.wire(...) pairs every route binding with its handler.
7
+ * 3. endpoint().use(httpKoa({prefix:"/api"})).mount(app).run() starts
8
+ * the HTTP listener + graceful drain + K8s probes.
14
9
  *
15
10
  * Once running:
16
- * - HTTP `POST /api/posts` → submitPost action → PostWasSubmitted event
17
- * - `autoModerate` workflow picks up the event, runs auto-check, dispatches
11
+ * - HTTP POST /api/posts → submitPost action → PostWasSubmitted event
12
+ * - autoModerate workflow picks up the event, runs auto-check, dispatches
18
13
  * approve/reject if obvious
19
- * - `queueDashboard` projection picks up ALL three events, updates state
20
- * - HTTP `GET /api/queue` reads from the projection
14
+ * - queueDashboard projection folds all three events into state
15
+ * - HTTP GET /api/queue reads from the projection
21
16
  *
22
17
  * Run: pnpm dev
23
18
  * Try: curl -X POST http://localhost:3000/api/posts \
@@ -27,12 +22,36 @@
27
22
  */
28
23
 
29
24
  import { endpoint } from "@nwire/endpoint";
30
- import { app } from "./app";
31
- import { api } from "./api";
25
+ import { httpKoa } from "@nwire/koa";
26
+ import { buildApp } from "./app";
27
+ import { wires } from "./api";
28
+
29
+ export interface BootstrapOptions {
30
+ readonly port?: number;
31
+ readonly test?: boolean;
32
+ }
32
33
 
33
- await app.start();
34
+ export async function bootstrap(opts: number | BootstrapOptions = 3000) {
35
+ const { port = 3000, test = false } =
36
+ typeof opts === "number" ? { port: opts, test: false } : opts;
34
37
 
35
- // Off-by-default in production; opt in here so Studio's Live page works.
36
- api.inspect(app);
38
+ const app = buildApp();
39
+ for (const { binding, handler } of wires) {
40
+ app.wire(binding, handler);
41
+ }
42
+ await app.start();
43
+ const koa = httpKoa({ port, prefix: "/api" });
44
+ const running = await endpoint("{{PROJECT_NAME}}", {
45
+ exitOnShutdown: !test,
46
+ banner: !test,
47
+ probes: { enabled: !test },
48
+ })
49
+ .use(koa)
50
+ .mount(app)
51
+ .run();
52
+ return { app, running, koa };
53
+ }
37
54
 
38
- await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
55
+ if (import.meta.url === `file://${process.argv[1]}`) {
56
+ await bootstrap();
57
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * POST /api/posts/:postId/approve — human moderator approves a post.
3
3
  *
4
- * Dispatches the SAME action that the auto-moderate workflow dispatches
5
- * that reuse is exactly why approvePost lives in `actions/` and not
4
+ * Dispatches the SAME action that the auto-moderate workflow dispatches
5
+ * that reuse is exactly why approvePost lives in `actions/` and not
6
6
  * inline in the route handler.
7
7
  */
8
8
 
9
- import { post, type HttpHandler } from "@nwire/http";
9
+ import { post } from "@nwire/wires/http";
10
+ import type { ForgeDispatcher } from "@nwire/forge";
10
11
  import { z } from "zod";
11
- import { app } from "../../../app/app";
12
12
  import { approvePost } from "../actions/approve-post";
13
13
 
14
14
  const Params = z.object({ postId: z.string() });
@@ -17,8 +17,12 @@ type Input = z.infer<typeof Params> & z.infer<typeof Body>;
17
17
 
18
18
  export const approvePostRoute = post("/posts/:postId/approve", { params: Params, body: Body });
19
19
 
20
- export const approvePostHandler: HttpHandler<Input> = async ({ input }) => {
21
- await app.runtime.dispatch(approvePost, {
20
+ export const approvePostHandler = async (
21
+ input: Input,
22
+ ctx: { resolve: <T>(name: string) => T },
23
+ ) => {
24
+ const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
25
+ await dispatcher.dispatch(approvePost, {
22
26
  postId: input.postId,
23
27
  approvedBy: input.moderatorId,
24
28
  });
@@ -5,16 +5,20 @@
5
5
  * dashboard (never submitted, or projection state was reset).
6
6
  */
7
7
 
8
- import { get, type HttpHandler } from "@nwire/http";
8
+ import { get } from "@nwire/wires/http";
9
+ import type { ForgeDispatcher } from "@nwire/forge";
9
10
  import { z } from "zod";
10
- import { app } from "../../../app/app";
11
11
 
12
12
  const Params = z.object({ postId: z.string() });
13
13
 
14
14
  export const getPostRoute = get("/posts/:postId", { params: Params });
15
15
 
16
- export const getPostHandler: HttpHandler<z.infer<typeof Params>> = async ({ input }) => {
17
- const post = await app.runtime.query("posts.get-post", input);
16
+ export const getPostHandler = async (
17
+ input: z.infer<typeof Params>,
18
+ ctx: { resolve: <T>(name: string) => T },
19
+ ) => {
20
+ const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
21
+ const post = await dispatcher.query("posts.get-post", input);
18
22
  if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
19
23
  return post;
20
24
  };
@@ -6,9 +6,9 @@
6
6
  * straight state read, no recomputation.
7
7
  */
8
8
 
9
- import { get, type HttpHandler } from "@nwire/http";
9
+ import { get } from "@nwire/wires/http";
10
+ import type { ForgeDispatcher } from "@nwire/forge";
10
11
  import { z } from "zod";
11
- import { app } from "../../../app/app";
12
12
 
13
13
  const Query = z.object({
14
14
  limit: z.coerce.number().int().min(1).max(100).default(20),
@@ -16,7 +16,13 @@ const Query = z.object({
16
16
 
17
17
  export const listQueueRoute = get("/queue", { query: Query });
18
18
 
19
- export const listQueueHandler: HttpHandler<z.infer<typeof Query>> = async ({ input }) => ({
20
- items: await app.runtime.query("posts.list-pending", input),
21
- totals: await app.runtime.query("posts.queue-totals", {}),
22
- });
19
+ export const listQueueHandler = async (
20
+ input: z.infer<typeof Query>,
21
+ ctx: { resolve: <T>(name: string) => T },
22
+ ) => {
23
+ const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
24
+ return {
25
+ items: await dispatcher.query("posts.list-pending", input),
26
+ totals: await dispatcher.query("posts.queue-totals", {}),
27
+ };
28
+ };
@@ -5,9 +5,9 @@
5
5
  * PostWasRejected event.
6
6
  */
7
7
 
8
- import { post, type HttpHandler } from "@nwire/http";
8
+ import { post } from "@nwire/wires/http";
9
+ import type { ForgeDispatcher } from "@nwire/forge";
9
10
  import { z } from "zod";
10
- import { app } from "../../../app/app";
11
11
  import { rejectPost } from "../actions/reject-post";
12
12
 
13
13
  const Params = z.object({ postId: z.string() });
@@ -19,8 +19,12 @@ type Input = z.infer<typeof Params> & z.infer<typeof Body>;
19
19
 
20
20
  export const rejectPostRoute = post("/posts/:postId/reject", { params: Params, body: Body });
21
21
 
22
- export const rejectPostHandler: HttpHandler<Input> = async ({ input }) => {
23
- await app.runtime.dispatch(rejectPost, {
22
+ export const rejectPostHandler = async (
23
+ input: Input,
24
+ ctx: { resolve: <T>(name: string) => T },
25
+ ) => {
26
+ const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
27
+ await dispatcher.dispatch(rejectPost, {
24
28
  postId: input.postId,
25
29
  rejectedBy: input.moderatorId,
26
30
  reason: input.reason,
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * POST /api/posts — author submits a draft for moderation.
3
3
  *
4
- * Returns 202 (Accepted) — the action recorded PostWasSubmitted; the
4
+ * Returns 202 (Accepted) — the action records PostWasSubmitted; the
5
5
  * auto-moderate workflow + dashboard projection run downstream. By the
6
6
  * time the client polls GET /api/queue the post may already be auto-decided.
7
- *
8
- * The route handler is a thin shim: translate request → action input,
9
- * dispatch, return the tagged response. All orchestration happens
10
- * downstream of the event the action emits — never inside the handler.
11
7
  */
12
8
 
13
- import { post, type HttpHandler } from "@nwire/http";
9
+ import { post } from "@nwire/wires/http";
10
+ import type { ForgeDispatcher } from "@nwire/forge";
14
11
  import { z } from "zod";
15
- import { app } from "../../../app/app";
16
12
  import { submitPost } from "../actions/submit-post";
17
13
 
18
14
  const Body = z.object({
@@ -22,7 +18,11 @@ const Body = z.object({
22
18
 
23
19
  export const submitPostRoute = post("/posts", { body: Body });
24
20
 
25
- export const submitPostHandler: HttpHandler<z.infer<typeof Body>> = async ({ input }) => {
26
- await app.runtime.dispatch(submitPost, input);
21
+ export const submitPostHandler = async (
22
+ input: z.infer<typeof Body>,
23
+ ctx: { resolve: <T>(name: string) => T },
24
+ ) => {
25
+ const dispatcher = ctx.resolve<ForgeDispatcher>("forge.dispatcher");
26
+ await dispatcher.dispatch(submitPost, input);
27
27
  return { $status: 202, body: { accepted: true } };
28
28
  };
@@ -8,15 +8,16 @@
8
8
  "test": "vitest run"
9
9
  },
10
10
  "dependencies": {
11
- "@nwire/app": "^0.9.2",
12
- "@nwire/endpoint": "^0.9.2",
13
- "@nwire/forge": "^0.9.2",
14
- "@nwire/http": "^0.9.2",
15
- "@nwire/messages": "^0.9.2",
11
+ "@nwire/app": "^0.10.0",
12
+ "@nwire/endpoint": "^0.10.0",
13
+ "@nwire/forge": "^0.10.0",
14
+ "@nwire/wires": "^0.10.0",
15
+ "@nwire/koa": "^0.10.0",
16
+ "@nwire/messages": "^0.10.0",
16
17
  "zod": "^4.0.0"
17
18
  },
18
19
  "devDependencies": {
19
- "@nwire/test-kit": "^0.9.2",
20
+ "@nwire/test-kit": "^0.10.0",
20
21
  "@types/node": "^22.19.9",
21
22
  "typescript": "^5.9.0",
22
23
  "vite-node": "^3.2.4",
@@ -2,31 +2,35 @@
2
2
  * Smoke test — POST /hello returns the personalized greeting, and an
3
3
  * empty `name` is rejected with a structured validation error.
4
4
  *
5
- * The test boots `api` (no full endpoint no port binding) and dispatches
6
- * raw fetch requests against `api.compile()` mounted on a Node http server.
7
- * That's the same boot path as production, just without the lightship
8
- * probes and SIGTERM dance.
5
+ * Boots the app through endpoint + httpKoa on an ephemeral port.
9
6
  */
10
7
 
11
8
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
12
- import http from "node:http";
13
- import type { Server } from "node:http";
14
- import { api } from "../app/api";
9
+ import { endpoint } from "@nwire/endpoint";
10
+ import { httpKoa } from "@nwire/koa";
11
+ import { buildApp } from "../app/main";
15
12
 
16
- let server: Server;
13
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
14
+ let app: ReturnType<typeof buildApp>;
17
15
  let url: string;
18
16
 
19
17
  beforeAll(async () => {
20
- server = http.createServer(api.compile());
21
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
22
- const addr = server.address() as { port: number };
23
- url = `http://127.0.0.1:${addr.port}`;
18
+ app = buildApp();
19
+ const koa = httpKoa({ port: 0 });
20
+ running = await endpoint("hello-test", {
21
+ exitOnShutdown: false,
22
+ banner: false,
23
+ probes: { enabled: false },
24
+ })
25
+ .use(koa)
26
+ .mount(app)
27
+ .run();
28
+ url = `http://127.0.0.1:${koa.port()}`;
24
29
  });
25
30
 
26
31
  afterAll(async () => {
27
- await new Promise<void>((resolve, reject) =>
28
- server.close((err) => (err ? reject(err) : resolve())),
29
- );
32
+ await running.shutdown("test");
33
+ await app.stop();
30
34
  });
31
35
 
32
36
  describe("hello route", () => {
@@ -1,23 +1,20 @@
1
1
  /**
2
- * HTTP interface — the surface this app exposes.
2
+ * Wires — the surface this app exposes.
3
3
  *
4
- * The interface is built by wiring route bindings to handlers. Each route
5
- * lives under `./routes/` and exports a `{name}Route` + `{name}Handler`
6
- * pair. The interface stays a 1-line-per-route reducer; new routes drop in
7
- * with a single import + a single `.wire(...)` call.
4
+ * Each route lives under `./routes/` and exports a `{name}Route` +
5
+ * `{name}Handler` pair. `wires` is a flat array of `{ binding, handler }`
6
+ * pairs that `main.ts` feeds into `app.wire(...)`.
8
7
  *
9
- * Why a folder of routes for "just one route"?
10
- *
11
- * - Adding route #2 is a single new file, not a rewrite of api.ts.
12
- * - Each route's verb + path + schema + handler live together.
13
- * - The pattern matches the service and enterprise templates, so
14
- * graduating up is mechanical.
8
+ * Adding route #2 is one new file + one new entry in this array.
15
9
  *
16
10
  * When you outgrow inline handlers (multiple bounded contexts, domain
17
- * events, persistence) graduate via `pnpm create nwire <name> --template service`.
11
+ * events, persistence) graduate via:
12
+ *
13
+ * pnpm create nwire <name> --template service
18
14
  */
19
15
 
20
- import { httpInterface } from "@nwire/http";
21
16
  import { helloRoute, helloHandler } from "./routes/hello";
22
17
 
23
- export const api = httpInterface().wire(helloRoute, helloHandler);
18
+ export const wires = [
19
+ { binding: helloRoute, handler: helloHandler },
20
+ ] as const;
@@ -1,27 +1,38 @@
1
1
  /**
2
- * Entry point — boot the HTTP interface inside an endpoint.
2
+ * Entry — boot the app under HTTP.
3
3
  *
4
- * The lifecycle:
4
+ * createApp(...) — the bounded context (container + wires)
5
+ * app.wire(...) — pair a binding with its handler
6
+ * endpoint().use(...) — install a transport adopter (HTTP, queue, MCP, …)
7
+ * .mount(app).run() — boot the app under the adopters; ready for traffic
5
8
  *
6
- * 1. `api` (./api.ts) is a built interface routes registered, schemas
7
- * compiled, ready to serve.
8
- * 2. `endpoint(name, { port }).serve(api).run()` starts the HTTP listener
9
- * AND wires graceful SIGTERM drain + K8s readiness/liveness probes.
10
- *
11
- * No createApp, no DI container, no domain primitives. Two packages,
12
- * three files of source code. The smallest possible Nwire shape.
9
+ * The minimal shape: no DI, no forge, no domain primitives. Two packages
10
+ * and three files. Graduate to `service` when you need a container, store,
11
+ * or structured errors.
13
12
  *
14
13
  * Run: pnpm dev
15
14
  * Try: curl -X POST http://localhost:3000/hello \
16
15
  * -H "content-type: application/json" \
17
16
  * -d '{"name":"Alice"}'
18
- *
19
- * Probes (K8s readiness/liveness):
20
- * curl http://localhost:9400/ready
21
- * curl http://localhost:9400/live
22
17
  */
23
18
 
19
+ import { createApp } from "@nwire/app";
24
20
  import { endpoint } from "@nwire/endpoint";
25
- import { api } from "./api";
21
+ import { httpKoa } from "@nwire/koa";
22
+ import { wires } from "./api";
23
+
24
+ export function buildApp() {
25
+ const app = createApp({ appName: "{{PROJECT_NAME}}" });
26
+ for (const { binding, handler } of wires) {
27
+ app.wire(binding, handler);
28
+ }
29
+ return app;
30
+ }
26
31
 
27
- await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
32
+ if (import.meta.url === `file://${process.argv[1]}`) {
33
+ const app = buildApp();
34
+ await endpoint("{{PROJECT_NAME}}", { port: 3000 })
35
+ .use(httpKoa())
36
+ .mount(app)
37
+ .run();
38
+ }