create-nwire 0.12.1 → 0.13.1

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 (66) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +6 -2
  3. package/package.json +1 -1
  4. package/templates/enterprise/AGENTS.md +11 -11
  5. package/templates/enterprise/README.md +15 -10
  6. package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
  7. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  8. package/templates/enterprise/app/api.ts +5 -1
  9. package/templates/enterprise/app/app.ts +23 -15
  10. package/templates/enterprise/app/main.ts +20 -45
  11. package/templates/enterprise/config/app.ts +18 -0
  12. package/templates/enterprise/config/env.ts +32 -0
  13. package/templates/enterprise/config/http.ts +15 -0
  14. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  15. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  16. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  17. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  18. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  19. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  20. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +34 -27
  21. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  22. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  23. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  24. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  25. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  26. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  27. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  28. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  29. package/templates/enterprise/package.json +10 -11
  30. package/templates/enterprise/tsconfig.json +1 -1
  31. package/templates/mcp/AGENTS.md +73 -0
  32. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  33. package/templates/mcp/_gitignore +5 -0
  34. package/templates/mcp/_npmrc +4 -0
  35. package/templates/mcp/_pnpm-workspace.yaml +12 -0
  36. package/templates/mcp/app/app.ts +24 -0
  37. package/templates/mcp/app/main.ts +197 -0
  38. package/templates/mcp/app/store/facts-store.ts +17 -0
  39. package/templates/mcp/app/tools/add-fact.ts +27 -0
  40. package/templates/mcp/app/tools/list-facts.ts +17 -0
  41. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  42. package/templates/mcp/app/tools.ts +19 -0
  43. package/templates/mcp/config/app.ts +16 -0
  44. package/templates/mcp/config/env.ts +27 -0
  45. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  46. package/templates/mcp/package.json +25 -0
  47. package/templates/mcp/tsconfig.json +15 -0
  48. package/templates/mcp/vitest.config.ts +8 -0
  49. package/templates/minimal/AGENTS.md +16 -9
  50. package/templates/minimal/README.md +20 -10
  51. package/templates/minimal/__tests__/hello.test.ts +3 -3
  52. package/templates/minimal/app/app.ts +21 -0
  53. package/templates/minimal/app/main.ts +15 -24
  54. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  55. package/templates/minimal/package.json +7 -8
  56. package/templates/service/AGENTS.md +13 -11
  57. package/templates/service/README.md +5 -3
  58. package/templates/service/__tests__/todo-api.test.ts +5 -5
  59. package/templates/service/app/app.ts +25 -0
  60. package/templates/service/app/main.ts +20 -30
  61. package/templates/service/app/store/todo-store.ts +6 -22
  62. package/templates/service/config/app.ts +18 -0
  63. package/templates/service/config/env.ts +32 -0
  64. package/templates/service/config/http.ts +15 -0
  65. package/templates/service/package.json +8 -9
  66. package/templates/service/tsconfig.json +1 -1
@@ -0,0 +1,24 @@
1
+ /**
2
+ * App — the composition root.
3
+ *
4
+ * `createApp` constructs the container and wires all MCP tools in this
5
+ * module body. The exported `app` is COMPLETE on import — no caller needs
6
+ * to wire or start it. No ports are bound here; that is `main.ts`'s job.
7
+ *
8
+ * Tests import `{ app }` from this file and drive it with `mcpAdapter()`
9
+ * directly — never import `main.ts` from tests, it owns the stdio loop
10
+ * and will hang the runner.
11
+ *
12
+ * Graduate: add `@nwire/mongo` or `@nwire/drizzle` as a plugin here when
13
+ * the in-memory store outgrows its welcome. The tool handlers keep calling
14
+ * `ctx.resolve("db")`; nothing else changes.
15
+ */
16
+
17
+ import { createApp } from "@nwire/app";
18
+ import { tools } from "./tools";
19
+
20
+ export const app = createApp({ appName: "{{PROJECT_NAME}}" });
21
+
22
+ for (const { binding, handler } of tools) {
23
+ app.wire(binding, handler);
24
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Entry — the only file that owns side effects.
3
+ *
4
+ * Boots the nwire app under the MCP adapter, then runs the JSON-RPC
5
+ * framing loop so AI clients (Claude, Cursor, IDE plugins) can drive this
6
+ * server over stdio.
7
+ *
8
+ * ┌─ STDOUT IS THE JSON-RPC CHANNEL ─────────────────────────────────┐
9
+ * │ │
10
+ * │ Every byte written to stdout MUST be a complete, newline-framed │
11
+ * │ JSON-RPC message. Non-JSON noise (console.log, banners, debug │
12
+ * │ text) breaks every MCP client silently. │
13
+ * │ │
14
+ * │ Log to stderr: console.error("msg") or process.stderr.write() │
15
+ * │ NEVER: console.log() process.stdout.write(plain text) │
16
+ * └────────────────────────────────────────────────────────────────────┘
17
+ *
18
+ * The mcpAdapter boots the nwire runtime so tools run through the normal
19
+ * dispatch path (validation, DI, observability). The stdio loop below
20
+ * translates JSON-RPC calls into adapter invocations.
21
+ *
22
+ * Tests import `{ app }` from `./app` and bypass this file entirely.
23
+ * Never import `main.ts` from a test — the stdio loop holds the process
24
+ * open and hangs the runner.
25
+ */
26
+
27
+ import { endpoint } from "@nwire/endpoint";
28
+ import { mcpAdapter } from "@nwire/mcp";
29
+ import { appConfig } from "../config/app";
30
+ import { app } from "./app";
31
+
32
+ const cfg = appConfig();
33
+
34
+ const mcp = mcpAdapter();
35
+
36
+ await endpoint(cfg.name, { banner: false, probes: { enabled: false } })
37
+ .use(mcp)
38
+ .mount(app)
39
+ .run();
40
+
41
+ // ─── JSON-RPC 2.0 stdio framing ─────────────────────────────────────
42
+
43
+ const SERVER_NAME = cfg.name;
44
+ const SERVER_VERSION = "0.0.0";
45
+
46
+ type JsonRpcResponse = {
47
+ jsonrpc: "2.0";
48
+ id: string | number | null;
49
+ result?: unknown;
50
+ error?: { code: number; message: string };
51
+ };
52
+
53
+ const send = (msg: JsonRpcResponse): void => {
54
+ process.stdout.write(JSON.stringify(msg) + "\n");
55
+ };
56
+
57
+ const log = (text: string): void => {
58
+ process.stderr.write(`[${SERVER_NAME}] ${text}\n`);
59
+ };
60
+
61
+ log("MCP server ready");
62
+
63
+ let buffer = "";
64
+
65
+ process.stdin.setEncoding("utf8");
66
+ process.stdin.on("data", (chunk: string) => {
67
+ buffer += chunk;
68
+ const lines = buffer.split(/\r?\n/);
69
+ buffer = lines.pop() ?? "";
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue;
72
+ void handleLine(line);
73
+ }
74
+ });
75
+
76
+ process.stdin.on("end", () => {
77
+ log("stdin closed — exiting");
78
+ process.exit(0);
79
+ });
80
+
81
+ async function handleLine(line: string): Promise<void> {
82
+ let req: { jsonrpc: string; id?: unknown; method: string; params?: Record<string, unknown> };
83
+ try {
84
+ req = JSON.parse(line) as typeof req;
85
+ } catch {
86
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
87
+ return;
88
+ }
89
+
90
+ // Notifications (no id, or notifications/* methods) must not be answered.
91
+ const isNotification = req.id === undefined || String(req.method).startsWith("notifications/");
92
+ if (isNotification) return;
93
+
94
+ const id = (req.id as string | number | null) ?? null;
95
+
96
+ try {
97
+ switch (req.method) {
98
+ case "initialize":
99
+ send({
100
+ jsonrpc: "2.0",
101
+ id,
102
+ result: {
103
+ protocolVersion: "2024-11-05",
104
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
105
+ capabilities: { tools: { listChanged: false } },
106
+ },
107
+ });
108
+ break;
109
+
110
+ case "tools/list": {
111
+ const listed = mcp.list().map((t) => ({
112
+ name: t.name,
113
+ description: t.description ?? "",
114
+ inputSchema: t.inputSchema
115
+ ? zodToJsonSchema(t.inputSchema)
116
+ : { type: "object", properties: {}, additionalProperties: false },
117
+ }));
118
+ send({ jsonrpc: "2.0", id, result: { tools: listed } });
119
+ break;
120
+ }
121
+
122
+ case "tools/call": {
123
+ const name = req.params?.name as string | undefined;
124
+ const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
125
+ if (!name) {
126
+ send({
127
+ jsonrpc: "2.0",
128
+ id,
129
+ error: { code: -32602, message: "tools/call requires `name`" },
130
+ });
131
+ break;
132
+ }
133
+ try {
134
+ const result = await mcp.call(name, args);
135
+ send({
136
+ jsonrpc: "2.0",
137
+ id,
138
+ result: {
139
+ content: [{ type: "text", text: JSON.stringify(result) }],
140
+ isError: false,
141
+ },
142
+ });
143
+ } catch (err) {
144
+ send({
145
+ jsonrpc: "2.0",
146
+ id,
147
+ result: {
148
+ content: [{ type: "text", text: (err as Error).message }],
149
+ isError: true,
150
+ },
151
+ });
152
+ }
153
+ break;
154
+ }
155
+
156
+ default:
157
+ send({
158
+ jsonrpc: "2.0",
159
+ id,
160
+ error: { code: -32601, message: `unknown method "${req.method}"` },
161
+ });
162
+ }
163
+ } catch (err) {
164
+ send({ jsonrpc: "2.0", id, error: { code: -32603, message: (err as Error).message } });
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Minimal Zod v4 → JSON Schema converter for the tools/list response.
170
+ * Handles the shapes the MCP tools in this template actually use —
171
+ * object with string/number properties. Extend as you add new types.
172
+ *
173
+ * Zod v4 uses `_def.type` (not `_def.typeName`) and `_def.shape` is a
174
+ * plain object (not a getter function).
175
+ */
176
+ function zodToJsonSchema(schema: { _def?: unknown }): unknown {
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ const def = (schema as any)._def;
179
+ if (!def) return { type: "object", additionalProperties: true };
180
+
181
+ if (def.type === "object") {
182
+ const props: Record<string, unknown> = {};
183
+ const required: string[] = [];
184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
185
+ for (const [k, v] of Object.entries(def.shape as Record<string, any>)) {
186
+ props[k] = zodToJsonSchema(v);
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ if ((v as any)._def?.type !== "optional") required.push(k);
189
+ }
190
+ return { type: "object", properties: props, required, additionalProperties: false };
191
+ }
192
+ if (def.type === "string") return { type: "string" };
193
+ if (def.type === "number") return { type: "number" };
194
+ if (def.type === "boolean") return { type: "boolean" };
195
+ if (def.type === "optional") return zodToJsonSchema(def.innerType);
196
+ return { type: "object", additionalProperties: true };
197
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * In-memory knowledge base — a simple mutable array that all tool handlers
3
+ * share within the process. Replace this with `@nwire/drizzle` or
4
+ * `@nwire/mongo` when you need persistence across restarts.
5
+ */
6
+
7
+ export interface Fact {
8
+ id: number;
9
+ topic: string;
10
+ body: string;
11
+ }
12
+
13
+ /** Seed data — something concrete so `list-facts` returns content on first run. */
14
+ export const facts: Fact[] = [
15
+ { id: 1, topic: "nwire", body: "One handler, every transport." },
16
+ { id: 2, topic: "mcp", body: "Model Context Protocol — AI clients call tools over JSON-RPC." },
17
+ ];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * add-fact — store a new fact in the knowledge base.
3
+ *
4
+ * The AI client supplies a topic and body; the tool persists it so future
5
+ * `lookup-fact` calls can surface it. Duplicate topics are allowed —
6
+ * the knowledge base treats each entry as an independent note.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { tool } from "@nwire/wires/mcp";
11
+ import { facts } from "../store/facts-store";
12
+
13
+ const AddFactInput = z.object({
14
+ topic: z.string().min(1).max(100).describe("Short label for this fact"),
15
+ body: z.string().min(1).max(1000).describe("The fact content"),
16
+ });
17
+
18
+ export const addFactTool = tool("add-fact", {
19
+ description: "Add a new fact to the knowledge base. Returns the stored entry.",
20
+ input: AddFactInput,
21
+ });
22
+
23
+ export const addFactHandler = async (input: z.infer<typeof AddFactInput>) => {
24
+ const entry = { id: facts.length + 1, topic: input.topic, body: input.body };
25
+ facts.push(entry);
26
+ return { stored: entry, total: facts.length };
27
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * list-facts — enumerate every fact in the knowledge base.
3
+ *
4
+ * Useful when the AI client wants a full picture of what is stored before
5
+ * deciding which facts to update or which lookup term to use.
6
+ */
7
+
8
+ import { tool } from "@nwire/wires/mcp";
9
+ import { facts } from "../store/facts-store";
10
+
11
+ export const listFactsTool = tool("list-facts", {
12
+ description: "Return all facts currently stored in the knowledge base.",
13
+ });
14
+
15
+ export const listFactsHandler = async () => {
16
+ return { count: facts.length, facts };
17
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * lookup-fact — find a fact by keyword from the in-memory knowledge base.
3
+ *
4
+ * The AI client submits a search term; the tool scans all stored facts
5
+ * for matches and returns the hits. Zero results is a valid (empty) answer,
6
+ * not an error.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { tool } from "@nwire/wires/mcp";
11
+ import { facts } from "../store/facts-store";
12
+
13
+ const LookupInput = z.object({
14
+ query: z.string().min(1).describe("Keyword or phrase to search for"),
15
+ });
16
+
17
+ export const lookupFactTool = tool("lookup-fact", {
18
+ description: "Search the knowledge base by keyword and return matching facts.",
19
+ input: LookupInput,
20
+ });
21
+
22
+ export const lookupFactHandler = async (input: z.infer<typeof LookupInput>) => {
23
+ const term = input.query.toLowerCase();
24
+ const matches = facts.filter(
25
+ (f) => f.topic.toLowerCase().includes(term) || f.body.toLowerCase().includes(term),
26
+ );
27
+ return { query: input.query, count: matches.length, results: matches };
28
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tools — the MCP surface this app exposes.
3
+ *
4
+ * Each tool file under `./tools/` exports a `{name}Tool` (binding) +
5
+ * `{name}Handler` (function). `tools` is the flat array fed into
6
+ * `app.wire(...)` in `app.ts`.
7
+ *
8
+ * Adding a tool = one new file + one entry in this array.
9
+ */
10
+
11
+ import { lookupFactTool, lookupFactHandler } from "./tools/lookup-fact";
12
+ import { addFactTool, addFactHandler } from "./tools/add-fact";
13
+ import { listFactsTool, listFactsHandler } from "./tools/list-facts";
14
+
15
+ export const tools = [
16
+ { binding: lookupFactTool, handler: lookupFactHandler },
17
+ { binding: addFactTool, handler: addFactHandler },
18
+ { binding: listFactsTool, handler: listFactsHandler },
19
+ ] as const;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * App-level settings — the process identity and lifecycle budgets the
3
+ * endpoint owns. A function of the typed `env`, so the same config file
4
+ * reads differently per environment without an `if` in sight.
5
+ */
6
+
7
+ import { env, type Env } from "./env";
8
+
9
+ export const appConfig = (e: Env = env) => ({
10
+ /** Endpoint + app name — shows in logs and telemetry. */
11
+ name: "{{PROJECT_NAME}}",
12
+ /** Print the boot banner outside of tests. */
13
+ banner: e.NODE_ENV !== "test",
14
+ });
15
+
16
+ export type AppConfig = ReturnType<typeof appConfig>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Typed environment — read `process.env` once, through a schema, and never
3
+ * touch it again from app code. Missing or malformed values fail loudly at
4
+ * boot with a readable message, not three layers deep at first request.
5
+ */
6
+
7
+ import { z } from "zod";
8
+
9
+ const EnvSchema = z.object({
10
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
11
+ });
12
+
13
+ export type Env = z.output<typeof EnvSchema>;
14
+
15
+ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
16
+ const parsed = EnvSchema.safeParse(source);
17
+ if (!parsed.success) {
18
+ const summary = parsed.error.issues
19
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
20
+ .join("\n");
21
+ throw new Error(`Invalid environment:\n${summary}`);
22
+ }
23
+ return parsed.data;
24
+ }
25
+
26
+ /** The validated environment, loaded once. */
27
+ export const env: Env = loadEnv();
@@ -0,0 +1 @@
1
+ {"version":"4.1.6","results":[[":__tests__/mcp-server.test.ts",{"duration":316.55000000000007,"failed":false}]]}
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "node --import tsx/esm app/main.ts",
8
+ "dev": "node --import tsx/esm --watch app/main.ts",
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@nwire/app": "^0.13.1",
14
+ "@nwire/endpoint": "^0.13.1",
15
+ "@nwire/mcp": "^0.13.1",
16
+ "@nwire/wires": "^0.13.1",
17
+ "zod": "^4.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.19.9",
21
+ "tsx": "^4.19.4",
22
+ "typescript": "^5.9.0",
23
+ "vitest": "^4.0.18"
24
+ }
25
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noEmit": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["app/**/*", "config/**/*", "__tests__/**/*"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["__tests__/**/*.{test,spec}.ts"],
7
+ },
8
+ });
@@ -9,22 +9,29 @@ handler never knows which transport called it.
9
9
  ```ts
10
10
  import { createApp } from "@nwire/app";
11
11
  import { endpoint } from "@nwire/endpoint";
12
- import { get } from "@nwire/wires/http";
12
+ import { post } from "@nwire/wires/http";
13
13
  import { httpKoa } from "@nwire/koa";
14
+ import { z } from "zod";
14
15
 
15
16
  const app = createApp({ appName: "app" });
16
- app.wire(get("/hello"), async () => ({ message: "hello" }));
17
- await endpoint("app", { port: 3000 }).use(httpKoa()).mount(app).run();
17
+ // The route declares its schema; the handler receives the validated input.
18
+ app.wire(post("/hello", { body: z.object({ name: z.string().min(1) }) }), async (input) => ({
19
+ message: `Hello, ${input.name}!`,
20
+ }));
21
+ await endpoint("app", { port: Number(process.env.PORT) || 3000 })
22
+ .use(httpKoa())
23
+ .mount(app)
24
+ .run();
18
25
  ```
19
26
 
20
27
  ## What's installed (don't import beyond these)
21
28
 
22
- | Primitive | Package |
23
- |---|---|
24
- | `createApp`, `definePlugin` | `@nwire/app` |
25
- | `endpoint` | `@nwire/endpoint` |
29
+ | Primitive | Package |
30
+ | -------------------------------- | ------------------- |
31
+ | `createApp`, `definePlugin` | `@nwire/app` |
32
+ | `endpoint` | `@nwire/endpoint` |
26
33
  | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
27
- | `httpKoa` | `@nwire/koa` |
34
+ | `httpKoa` | `@nwire/koa` |
28
35
 
29
36
  This tier is HTTP-only. Add capabilities by installing the package first:
30
37
  `@nwire/handler` for `defineResource`/`defineError`, `@nwire/messages` for
@@ -33,5 +40,5 @@ This tier is HTTP-only. Add capabilities by installing the package first:
33
40
 
34
41
  ## Commands
35
42
 
36
- - `pnpm dev` — run the app (HTTP on :3000). `pnpm test` — tests.
43
+ - `pnpm dev` — wire + Studio on http://localhost:4000. `pnpm test` — tests.
37
44
  - `pnpm doctor` — health-check the setup. `pnpm studio` — trace console.
@@ -16,8 +16,10 @@ pnpm dev
16
16
 
17
17
  ## Try
18
18
 
19
+ `pnpm dev` serves the wire + Studio on http://localhost:4000.
20
+
19
21
  ```bash
20
- curl -X POST http://localhost:3000/hello \
22
+ curl -X POST http://localhost:4000/hello \
21
23
  -H "content-type: application/json" \
22
24
  -d '{"name":"Alice"}'
23
25
  ```
@@ -33,8 +35,9 @@ pnpm test
33
35
  ```
34
36
  {{PROJECT_NAME}}/
35
37
  ├── app/
36
- │ ├── main.ts ← endpoint().serve(api).run()
37
- │ ├── api.ts ← httpInterface().wire(route, handler)
38
+ │ ├── main.ts ← endpoint().use(httpKoa()).mount(app).run()
39
+ │ ├── app.ts ← createApp().wire(...) — the app value
40
+ │ ├── api.ts ← wires[] (route + handler pairs)
38
41
  │ └── routes/
39
42
  │ └── hello.ts ← POST /hello — route + handler pair
40
43
  └── __tests__/
@@ -51,13 +54,20 @@ pnpm test
51
54
 
52
55
  ## What you get free
53
56
 
54
- | Feature | Comes from |
55
- | -------------------------------------- | ----------------- |
56
- | Zod request validation | `@nwire/http` |
57
- | OpenAPI schema (visit `/openapi.json`) | `@nwire/http` |
58
- | Scalar UI docs (visit `/docs`) | `@nwire/http` |
59
- | Graceful SIGTERM drain | `@nwire/endpoint` |
60
- | K8s probes on port 9400 | `@nwire/endpoint` |
57
+ | Feature | Comes from |
58
+ | ------------------------------------ | ----------------- |
59
+ | Zod request validation | `@nwire/koa` |
60
+ | Graceful SIGTERM drain | `@nwire/endpoint` |
61
+ | K8s probes (port 9400/`$PROBE_PORT`) | `@nwire/endpoint` |
62
+
63
+ OpenAPI + Scalar docs are one opt-in away — pass them to the transport:
64
+
65
+ ```ts
66
+ endpoint("{{PROJECT_NAME}}")
67
+ .use(httpKoa({ openapi: { auto: true }, docs: true })) // /openapi.json + /docs
68
+ .mount(app)
69
+ .run();
70
+ ```
61
71
 
62
72
  ## Grow up
63
73
 
@@ -3,19 +3,19 @@
3
3
  * empty `name` is rejected with a structured validation error.
4
4
  *
5
5
  * Boots the app through endpoint + httpKoa on an ephemeral port.
6
+ * Imports `{ app }` from `../app/app` (the pure value). Never imports
7
+ * `main.ts` — that file boots a real server and hangs the test runner.
6
8
  */
7
9
 
8
10
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
9
11
  import { endpoint } from "@nwire/endpoint";
10
12
  import { httpKoa } from "@nwire/koa";
11
- import { buildApp } from "../app/main";
13
+ import { app } from "../app/app";
12
14
 
13
15
  let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
14
- let app: ReturnType<typeof buildApp>;
15
16
  let url: string;
16
17
 
17
18
  beforeAll(async () => {
18
- app = buildApp();
19
19
  const koa = httpKoa({ port: 0 });
20
20
  running = await endpoint("hello-test", {
21
21
  exitOnShutdown: false,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * App — the composition root.
3
+ *
4
+ * `createApp` constructs the container and wires all routes in this module
5
+ * body. The exported `app` is COMPLETE on import — no caller needs to wire
6
+ * or start it. No ports are bound here; that is `main.ts`'s job.
7
+ *
8
+ * Tests import `{ app }` from this file and build an isolated endpoint.
9
+ * Never import `main.ts` from tests: it boots a server and hangs the runner.
10
+ */
11
+
12
+ import { createApp } from "@nwire/app";
13
+ import { wires } from "./api";
14
+
15
+ const _app = createApp({ appName: "{{PROJECT_NAME}}" });
16
+
17
+ for (const { binding, handler } of wires) {
18
+ _app.wire(binding, handler);
19
+ }
20
+
21
+ export const app = _app;
@@ -1,33 +1,24 @@
1
1
  /**
2
- * Entry — boot the app under HTTP.
2
+ * Entry — the only file that boots a real HTTP server.
3
3
  *
4
- * createApp(...) — the bounded context (container + wires)
5
- * app.wire(...) — pair a binding with its handler
6
- * endpoint().use(...) — install a transport adapter (HTTP, queue, MCP, …)
7
- * .mount(app).run() — boot the app under the adapters; ready for traffic
4
+ * pnpm dev # wire + Studio on http://localhost:4000
8
5
  *
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.
6
+ * curl -X POST http://localhost:4000/hello \
7
+ * -H "content-type: application/json" \
8
+ * -d '{"name":"Alice"}'
12
9
  *
13
- * Run: pnpm dev
14
- * Try: curl -X POST http://localhost:3000/hello \
15
- * -H "content-type: application/json" \
16
- * -d '{"name":"Alice"}'
10
+ * `app` is a pure value (no side effects on import — see `./app`). This
11
+ * file adds the one side effect: binding a port and serving traffic.
12
+ * Tests import `{ app }` from `./app` and build their own ephemeral
13
+ * endpoint — never import this file from tests or it boots a server and
14
+ * hangs the runner.
17
15
  */
18
16
 
19
- import { createApp } from "@nwire/app";
20
17
  import { endpoint } from "@nwire/endpoint";
21
18
  import { httpKoa } from "@nwire/koa";
22
- import { wires } from "./api";
19
+ import { app } from "./app";
23
20
 
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
- }
31
-
32
- const app = buildApp();
33
- await endpoint("{{PROJECT_NAME}}", { port: 3000 }).use(httpKoa()).mount(app).run();
21
+ await endpoint("{{PROJECT_NAME}}", { port: Number(process.env.PORT) || 3000 })
22
+ .use(httpKoa())
23
+ .mount(app)
24
+ .run();
@@ -0,0 +1 @@
1
+ {"version":"4.1.6","results":[[":__tests__/hello.test.ts",{"duration":38.90391699999998,"failed":false}]]}
@@ -4,24 +4,23 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite-node app/main.ts",
7
+ "dev": "nwire dev",
8
8
  "test": "vitest run",
9
9
  "doctor": "nwire doctor",
10
10
  "studio": "nwire studio",
11
11
  "cache": "nwire cache"
12
12
  },
13
13
  "dependencies": {
14
- "@nwire/app": "^0.12.1",
15
- "@nwire/endpoint": "^0.12.1",
16
- "@nwire/koa": "^0.12.1",
17
- "@nwire/wires": "^0.12.1",
14
+ "@nwire/app": "^0.13.1",
15
+ "@nwire/endpoint": "^0.13.1",
16
+ "@nwire/koa": "^0.13.1",
17
+ "@nwire/wires": "^0.13.1",
18
18
  "zod": "^4.0.0"
19
19
  },
20
20
  "devDependencies": {
21
+ "@nwire/cli": "^0.13.1",
21
22
  "@types/node": "^22.19.9",
22
23
  "typescript": "^5.9.0",
23
- "vite-node": "^3.2.4",
24
- "vitest": "^4.0.18",
25
- "@nwire/cli": "^0.12.1"
24
+ "vitest": "^4.0.18"
26
25
  }
27
26
  }