create-nwire 0.9.1 → 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.
- package/README.md +7 -8
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/package.json +1 -1
- package/templates/enterprise/__tests__/auto-moderate.test.ts +6 -14
- package/templates/enterprise/__tests__/submit-flow.test.ts +6 -14
- package/templates/enterprise/app/api.ts +11 -18
- package/templates/enterprise/app/app.ts +37 -21
- package/templates/enterprise/app/main.ts +40 -21
- package/templates/enterprise/modules/posts/routes/approve-post.ts +10 -6
- package/templates/enterprise/modules/posts/routes/get-post.ts +8 -4
- package/templates/enterprise/modules/posts/routes/list-queue.ts +12 -6
- package/templates/enterprise/modules/posts/routes/reject-post.ts +8 -4
- package/templates/enterprise/modules/posts/routes/submit-post.ts +9 -9
- package/templates/enterprise/package.json +7 -6
- package/templates/minimal/__tests__/hello.test.ts +19 -15
- package/templates/minimal/app/api.ts +11 -14
- package/templates/minimal/app/main.ts +26 -15
- package/templates/minimal/app/routes/hello.ts +6 -13
- package/templates/minimal/package.json +4 -2
- package/templates/service/README.md +1 -1
- package/templates/service/__tests__/todo-api.test.ts +21 -20
- package/templates/service/app/api.ts +11 -20
- package/templates/service/app/main.ts +31 -20
- package/templates/service/app/middleware/require-user.ts +10 -11
- package/templates/service/app/routes/complete-todo.ts +8 -11
- package/templates/service/app/routes/create-todo.ts +9 -10
- package/templates/service/app/routes/delete-todo.ts +9 -9
- package/templates/service/app/routes/list-todos.ts +8 -11
- package/templates/service/app/store/todo-store.ts +2 -2
- package/templates/service/package.json +7 -5
- package/dist/__tests__/scaffold.test.d.ts +0 -7
- package/dist/__tests__/scaffold.test.d.ts.map +0 -1
- package/dist/__tests__/scaffold.test.js +0 -113
- package/dist/__tests__/scaffold.test.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- 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. `
|
|
21
|
-
| `service` | Todo CRUD. Container
|
|
22
|
-
| `enterprise` | Moderation queue.
|
|
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,
|
|
33
|
-
- **Graceful shutdown** via `@nwire/endpoint` (SIGTERM drain +
|
|
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
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -10,27 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
13
|
-
import
|
|
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
|
|
15
|
+
let running: Awaited<ReturnType<typeof bootstrap>>;
|
|
19
16
|
let url: string;
|
|
20
17
|
|
|
21
18
|
beforeAll(async () => {
|
|
22
|
-
await
|
|
23
|
-
|
|
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
|
|
31
|
-
|
|
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
|
|
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
|
|
9
|
+
let running: Awaited<ReturnType<typeof bootstrap>>;
|
|
13
10
|
let url: string;
|
|
14
11
|
|
|
15
12
|
beforeAll(async () => {
|
|
16
|
-
await
|
|
17
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
*
|
|
2
|
+
* Wires — the surface this app exposes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
* `
|
|
2
|
+
* `buildApp` composes the posts bounded context into a runnable App.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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/
|
|
24
|
-
import {
|
|
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 =
|
|
27
|
-
modules: [postsModule],
|
|
28
|
-
});
|
|
44
|
+
export const app = buildApp();
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry — boot the
|
|
2
|
+
* Entry — boot the app under HTTP.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
17
|
-
* -
|
|
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
|
-
* -
|
|
20
|
-
* - HTTP
|
|
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 {
|
|
31
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
21
|
-
|
|
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
|
|
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
|
|
17
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
26
|
-
|
|
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.
|
|
12
|
-
"@nwire/endpoint": "^0.
|
|
13
|
-
"@nwire/forge": "^0.
|
|
14
|
-
"@nwire/
|
|
15
|
-
"@nwire/
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
9
|
+
import { endpoint } from "@nwire/endpoint";
|
|
10
|
+
import { httpKoa } from "@nwire/koa";
|
|
11
|
+
import { buildApp } from "../app/main";
|
|
15
12
|
|
|
16
|
-
let
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
*
|
|
2
|
+
* Wires — the surface this app exposes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
18
|
+
export const wires = [
|
|
19
|
+
{ binding: helloRoute, handler: helloHandler },
|
|
20
|
+
] as const;
|
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry
|
|
2
|
+
* Entry — boot the app under HTTP.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|