Skip to content

Commit 4b42863

Browse files
authored
Merge pull request #48 from bombshell-dev/feat/direct-route-composition
✨ Support direct route and command composition
2 parents 0d41f2d + 6f71bd0 commit 4b42863

17 files changed

Lines changed: 487 additions & 295 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@bomb.sh/router": minor
3+
---
4+
5+
Allows `route()`, `command()`, and `extend()` to accept child routes and
6+
commands directly, preserving inferred route paths, methods, and models.
7+
8+
The public `routes()` helper has been removed. Pass children directly inside
9+
`route()` and `command()`, and use `extend(child, other)` for reusable groups or
10+
dynamic resolver results.

‎README.md‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
name,
4141
option,
4242
route,
43-
routes,
4443
schema,
4544
toggle,
4645
version,
@@ -52,19 +51,15 @@ export const app = command(
5251
description("Run and manage local service simulators."),
5352
version("1.0.0"),
5453
toggle(name("verbose")),
55-
routes(
54+
command(
55+
name("serve"),
56+
option(name("port"), schema(z.number().default(4000))),
57+
),
58+
route(
59+
name("database"),
5660
command(
57-
name("serve"),
58-
option(name("port"), schema(z.number().default(4000))),
59-
),
60-
route(
61-
name("database"),
62-
routes(
63-
command(
64-
name("clean"),
65-
toggle(name("dryRun")),
66-
),
67-
),
61+
name("clean"),
62+
toggle(name("dryRun")),
6863
),
6964
),
7065
);
@@ -79,6 +74,10 @@ HELP /database
7974
HELP /database/clean EXECUTE /database/clean
8075
```
8176

77+
Nest `route()` and `command()` directly inside another route, command, or
78+
`extend()`. Each child is mounted at that point in the pipeline. Use `extend()`
79+
to package reusable groups of children and other elements.
80+
8281
The root name identifies the executable; it is not repeated in route IDs.
8382
`simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`.
8483

@@ -200,14 +199,14 @@ to `resume()`. The schema's input type determines what `resume()` accepts; its
200199
validated output is passed to the extension, including any schema transforms.
201200

202201
```ts
203-
import { command, dynamic, name, parse, routes } from "@bomb.sh/router";
202+
import { command, dynamic, extend, name, parse } from "@bomb.sh/router";
204203
import * as z from "zod";
205204

206205
const app = command(
207206
name("plugins"),
208207
dynamic(
209208
z.array(z.string()),
210-
(plugins) => routes(...plugins.map((plugin) => command(name(plugin)))),
209+
(plugins) => extend(...plugins.map((plugin) => command(name(plugin)))),
211210
),
212211
);
213212

‎examples/routes-checkpoint.ts‎

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
name,
66
option,
77
route,
8-
routes,
98
schema,
109
toggle,
1110
version,
@@ -18,30 +17,26 @@ export const app = command(
1817
description("Run and manage local service simulators."),
1918
version("1.0.0"),
2019
toggle(name("verbose")),
21-
routes(
20+
command(
21+
name("serve"),
22+
option(name("port"), description("server port"), schema(z.number())),
23+
checkpoint(),
24+
option(name("host"), description("server host"), schema(z.string())),
25+
option(
26+
name("protocol"),
27+
description("server protocol"),
28+
schema(z.enum(["http", "https"])),
29+
),
30+
),
31+
route(
32+
name("database"),
2233
command(
23-
name("serve"),
24-
option(name("port"), description("server port"), schema(z.number())),
25-
checkpoint(),
26-
option(name("host"), description("server host"), schema(z.string())),
34+
name("clean"),
35+
toggle(name("dryRun")),
2736
option(
28-
name("protocol"),
29-
description("server protocol"),
30-
schema(z.enum(["http", "https"])),
31-
),
32-
),
33-
route(
34-
name("database"),
35-
routes(
36-
command(
37-
name("clean"),
38-
toggle(name("dryRun")),
39-
option(
40-
name("output"),
41-
description("output path"),
42-
schema(z.string()),
43-
),
44-
),
37+
name("output"),
38+
description("output path"),
39+
schema(z.string()),
4540
),
4641
),
4742
),

‎examples/routes.ts‎

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
name,
55
option,
66
route,
7-
routes,
87
schema,
98
toggle,
109
version,
@@ -17,29 +16,25 @@ export const app = command(
1716
description("Run and manage local service simulators."),
1817
version("1.0.0"),
1918
toggle(name("verbose")),
20-
routes(
19+
command(
20+
name("serve"),
21+
option(name("port"), description("server port"), schema(z.number())),
22+
option(name("host"), description("server host"), schema(z.string())),
23+
option(
24+
name("protocol"),
25+
description("server protocol"),
26+
schema(z.enum(["http", "https"])),
27+
),
28+
),
29+
route(
30+
name("database"),
2131
command(
22-
name("serve"),
23-
option(name("port"), description("server port"), schema(z.number())),
24-
option(name("host"), description("server host"), schema(z.string())),
32+
name("clean"),
33+
toggle(name("dryRun")),
2534
option(
26-
name("protocol"),
27-
description("server protocol"),
28-
schema(z.enum(["http", "https"])),
29-
),
30-
),
31-
route(
32-
name("database"),
33-
routes(
34-
command(
35-
name("clean"),
36-
toggle(name("dryRun")),
37-
option(
38-
name("output"),
39-
description("output path"),
40-
schema(z.string()),
41-
),
42-
),
35+
name("output"),
36+
description("output path"),
37+
schema(z.string()),
4338
),
4439
),
4540
),

‎lib/command.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
// deno-lint-ignore-file ban-types
2-
import type { Check, Fold, Materialize, Unary } from "./pipeline.ts";
2+
import type { Fold, Materialize } from "./pipeline.ts";
3+
import {
4+
type Input,
5+
type Normalize,
6+
normalize,
7+
type Validate,
8+
} from "./route.ts";
39
import type { Definition, Done, Route } from "./types.ts";
410

511
export type CommandZero<N extends string = string> = Route<
@@ -10,11 +16,11 @@ export type CommandZero<N extends string = string> = Route<
1016

1117
export function command<
1218
const N extends string,
13-
const E extends readonly Unary[],
19+
const E extends readonly Input[],
1420
>(
1521
start: Definition<N>,
16-
...elements: E & Check<CommandZero<N>, E>
17-
): Materialize<Fold<CommandZero<N>, E>> {
22+
...elements: E & Validate<CommandZero<N>, E>
23+
): Materialize<Fold<CommandZero<N>, Normalize<E>>> {
1824
let zero: CommandZero<N> = {
1925
...start,
2026
methods: ["help", "execute"],
@@ -29,8 +35,8 @@ export function command<
2935
}],
3036
};
3137

32-
return elements.reduce<unknown>(
38+
return normalize<E>(elements).reduce<unknown>(
3339
(value, element) => element(value as never),
3440
zero,
35-
) as Materialize<Fold<CommandZero<N>, E>>;
41+
) as Materialize<Fold<CommandZero<N>, Normalize<E>>>;
3642
}

‎lib/extend.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { brand, type Extension, type Unary } from "./pipeline.ts";
1+
import { type Input, type Normalize, normalize } from "./route.ts";
2+
import { brand, type Extension } from "./pipeline.ts";
23

3-
export function extend<const E extends readonly Unary[]>(
4+
export function extend<const E extends readonly Input[]>(
45
...elements: E
5-
): Extension<E> {
6-
return brand<Extension<E>>((start: unknown): unknown =>
7-
elements.reduce<unknown>(
6+
): Extension<Normalize<E>> {
7+
let normalized = normalize(elements);
8+
return brand<Extension<Normalize<E>>>((start: unknown): unknown =>
9+
normalized.reduce<unknown>(
810
(value, element) => element(value as never),
911
start,
1012
)

‎lib/route.ts‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// deno-lint-ignore-file ban-types
22
import {
33
brand,
4-
type Check,
54
type Fold,
65
type Materialize,
76
type MethodElement,
@@ -18,11 +17,11 @@ export type RouteZero<N extends string = string> = Route<
1817

1918
export function route<
2019
const N extends string,
21-
const E extends readonly Unary[],
20+
const E extends readonly Input[],
2221
>(
2322
start: Definition<N>,
24-
...elements: E & Check<RouteZero<N>, E>
25-
): Materialize<Fold<RouteZero<N>, E>> {
23+
...elements: E & Validate<RouteZero<N>, E>
24+
): Materialize<Fold<RouteZero<N>, Normalize<E>>> {
2625
let zero: RouteZero<N> = {
2726
...start,
2827
methods: ["help"],
@@ -37,10 +36,10 @@ export function route<
3736
}],
3837
};
3938

40-
return elements.reduce<unknown>(
39+
return normalize<E>(elements).reduce<unknown>(
4140
(value, element) => element(value as never),
4241
zero,
43-
) as Materialize<Fold<RouteZero<N>, E>>;
42+
) as Materialize<Fold<RouteZero<N>, Normalize<E>>>;
4443
}
4544

4645
export function version(semver: string): MethodElement<"version"> {
@@ -58,7 +57,25 @@ export function executable(): MethodElement<"execute"> {
5857
}));
5958
}
6059

61-
export function routes<const C extends readonly AnyRoute[]>(
60+
export type Input = Unary | AnyRoute;
61+
62+
export type Normalize<E extends readonly Input[]> = {
63+
[K in keyof E]: Normalized<E[K]>;
64+
};
65+
66+
// Walk invalid pipelines only, reporting errors on the original arguments.
67+
export type Validate<S, E extends readonly Input[]> =
68+
[Fold<S, Normalize<E>>] extends [never] ? Invalid<S, E> : unknown;
69+
70+
export function normalize<const E extends readonly Input[]>(
71+
elements: E,
72+
): Normalize<E> {
73+
return elements.map((element) =>
74+
typeof element === "function" ? element : routes(element)
75+
) as Normalize<E>;
76+
}
77+
78+
function routes<const C extends readonly AnyRoute[]>(
6279
...children: C
6380
): RoutesElement<C> {
6481
return brand<RoutesElement<C>>((route: AnyRoute) => {
@@ -75,3 +92,18 @@ export function routes<const C extends readonly AnyRoute[]>(
7592
};
7693
});
7794
}
95+
96+
// Distribute over unions so Fold can retain its conservative union handling.
97+
type Normalized<E extends Input> = E extends Unary ? E
98+
: E extends AnyRoute ? RoutesElement<readonly [E]>
99+
: never;
100+
101+
type Invalid<S, E extends readonly Input[]> = E extends readonly [
102+
infer Head extends Input,
103+
...infer Tail extends readonly Input[],
104+
]
105+
? Fold<S, readonly [Normalized<Head>]> extends infer Next
106+
? [Next] extends [never] ? readonly [never, ...Tail]
107+
: readonly [Head, ...Invalid<Next, Tail>]
108+
: never
109+
: E;

‎mod.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export type {
3535
Symbol as CLISymbol,
3636
} from "./lib/read.ts";
3737

38-
export { executable, route, routes, version } from "./lib/route.ts";
38+
export { executable, route, version } from "./lib/route.ts";
3939
export type { RouteZero } from "./lib/route.ts";
4040

4141
export { toggle } from "./lib/toggle.ts";

‎test/argument.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
name,
1010
option,
1111
parse,
12-
routes,
1312
schema,
1413
toggle,
1514
} from "@bomb.sh/router";
@@ -168,7 +167,7 @@ describe("argument()", () => {
168167
let app = command(
169168
name("app"),
170169
argument(name("workspace"), schema(type("string"))),
171-
routes(auth0),
170+
auth0,
172171
);
173172
let result = parse(app, {
174173
argv: ["local", "auth0", "service.json"],
@@ -191,7 +190,7 @@ describe("argument()", () => {
191190
let app = command(
192191
name("app"),
193192
argument(name("workspace"), schema(type("string | undefined"))),
194-
routes(auth0),
193+
auth0,
195194
);
196195
let result = parse(app, { argv: ["auth0"] });
197196

@@ -345,7 +344,7 @@ describe("argument()", () => {
345344
argument(name("target"), schema(type("string"))),
346345
dynamic(type("unknown"), () =>
347346
extend(
348-
routes(command(name("auth0"))),
347+
command(name("auth0")),
349348
)),
350349
);
351350
let increment = parse(app, { argv: ["auth0"] });

0 commit comments

Comments
 (0)