@bomb.sh/router 0.0.1 → 0.7.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 (90) hide show
  1. package/README.md +293 -0
  2. package/esm/_dnt.polyfills.d.ts +11 -0
  3. package/esm/_dnt.polyfills.js +15 -0
  4. package/esm/lib/argument.d.ts +7 -0
  5. package/esm/lib/argument.js +66 -0
  6. package/esm/lib/bind.d.ts +41 -0
  7. package/esm/lib/bind.js +285 -0
  8. package/esm/lib/checkpoint.d.ts +3 -0
  9. package/esm/lib/checkpoint.js +5 -0
  10. package/esm/lib/command.d.ts +6 -0
  11. package/esm/lib/command.js +16 -0
  12. package/esm/lib/dasherize.d.ts +1 -0
  13. package/esm/lib/dasherize.js +7 -0
  14. package/esm/lib/decode.d.ts +6 -0
  15. package/esm/lib/decode.js +46 -0
  16. package/esm/lib/definition.d.ts +4 -0
  17. package/esm/lib/definition.js +10 -0
  18. package/esm/lib/dynamic.d.ts +6 -0
  19. package/esm/lib/dynamic.js +21 -0
  20. package/esm/lib/env.d.ts +34 -0
  21. package/esm/lib/env.js +83 -0
  22. package/esm/lib/extend.d.ts +2 -0
  23. package/esm/lib/extend.js +4 -0
  24. package/esm/lib/maybe.d.ts +9 -0
  25. package/esm/lib/maybe.js +12 -0
  26. package/esm/lib/multiple.d.ts +8 -0
  27. package/esm/lib/multiple.js +9 -0
  28. package/esm/lib/option.d.ts +7 -0
  29. package/esm/lib/option.js +31 -0
  30. package/esm/lib/param.d.ts +26 -0
  31. package/esm/lib/param.js +37 -0
  32. package/esm/lib/parse.d.ts +2 -0
  33. package/esm/lib/parse.js +338 -0
  34. package/esm/lib/pipeline.d.ts +182 -0
  35. package/esm/lib/pipeline.js +6 -0
  36. package/esm/lib/print.d.ts +7 -0
  37. package/esm/lib/print.js +156 -0
  38. package/esm/lib/read.d.ts +27 -0
  39. package/esm/lib/read.js +95 -0
  40. package/esm/lib/rest.d.ts +9 -0
  41. package/esm/lib/rest.js +1 -0
  42. package/esm/lib/result.d.ts +9 -0
  43. package/esm/lib/result.js +1 -0
  44. package/esm/lib/route.d.ts +9 -0
  45. package/esm/lib/route.js +45 -0
  46. package/esm/lib/toggle.d.ts +7 -0
  47. package/esm/lib/toggle.js +91 -0
  48. package/esm/lib/tokenize.d.ts +20 -0
  49. package/esm/lib/tokenize.js +41 -0
  50. package/esm/lib/tokenizer.d.ts +34 -0
  51. package/esm/lib/tokenizer.js +130 -0
  52. package/esm/lib/transform.d.ts +8 -0
  53. package/esm/lib/transform.js +34 -0
  54. package/esm/lib/types.d.ts +196 -0
  55. package/esm/lib/types.js +1 -0
  56. package/esm/lib/values.d.ts +29 -0
  57. package/esm/lib/values.js +83 -0
  58. package/esm/mod.d.ts +29 -0
  59. package/esm/mod.js +18 -0
  60. package/esm/package.json +3 -0
  61. package/package.json +27 -9
  62. package/src/_dnt.polyfills.ts +27 -0
  63. package/src/lib/argument.ts +100 -0
  64. package/src/lib/bind.ts +421 -0
  65. package/src/lib/checkpoint.ts +10 -0
  66. package/src/lib/command.ts +36 -0
  67. package/src/lib/dasherize.ts +7 -0
  68. package/src/lib/decode.ts +55 -0
  69. package/src/lib/definition.ts +17 -0
  70. package/src/lib/dynamic.ts +39 -0
  71. package/src/lib/env.ts +142 -0
  72. package/src/lib/extend.ts +12 -0
  73. package/src/lib/maybe.ts +22 -0
  74. package/src/lib/multiple.ts +23 -0
  75. package/src/lib/option.ts +57 -0
  76. package/src/lib/param.ts +96 -0
  77. package/src/lib/parse.ts +478 -0
  78. package/src/lib/pipeline.ts +549 -0
  79. package/src/lib/print.ts +216 -0
  80. package/src/lib/read.ts +133 -0
  81. package/src/lib/rest.ts +10 -0
  82. package/src/lib/result.ts +10 -0
  83. package/src/lib/route.ts +77 -0
  84. package/src/lib/toggle.ts +132 -0
  85. package/src/lib/tokenize.ts +74 -0
  86. package/src/lib/tokenizer.ts +191 -0
  87. package/src/lib/transform.ts +49 -0
  88. package/src/lib/types.ts +460 -0
  89. package/src/lib/values.ts +131 -0
  90. package/src/mod.ts +74 -0
package/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # @bomb.sh/router
2
+
3
+ **A statically typed entry-point router for command-line applications.**
4
+
5
+ An argument parser tells you what the user typed. `@bomb.sh/router` tells you
6
+ where and how they intend to enter your program. For execution, it binds a
7
+ validated, statically typed model for that entry point.
8
+
9
+ `@bomb.sh/router` matches input to an intent: a method at an
10
+ application-relative route, such as:
11
+
12
+ ```text
13
+ HELP /
14
+ VERSION /
15
+ EXECUTE /serve
16
+ HELP /database/clean
17
+ EXECUTE /database/clean
18
+ ```
19
+
20
+ Every reachable intent appears in the result type. Narrow `method` and `route`,
21
+ and TypeScript knows the exact model available at that entry point.
22
+
23
+ `@bomb.sh/router` is not a CLI framework. It does not own handlers, effects,
24
+ output, or process lifetime. It is not a CLI parser whose product is a bag of
25
+ flags. Its product is a typed intent; your application decides what that intent
26
+ does.
27
+
28
+ ## Define every way into the program
29
+
30
+ `route()` declares an address, and every `command()` is just an address that
31
+ declares an intent to execute. Every route supports help, whereas version and
32
+ execution exist only where they are explicitly added.
33
+
34
+ Definitions are immutable composition pipelines, not handler registrations:
35
+
36
+ ```ts
37
+ import {
38
+ command,
39
+ description,
40
+ name,
41
+ option,
42
+ route,
43
+ routes,
44
+ schema,
45
+ toggle,
46
+ version,
47
+ } from "@bomb.sh/router";
48
+ import * as z from "zod";
49
+
50
+ export const app = command(
51
+ name("simulacrum"),
52
+ description("Run and manage local service simulators."),
53
+ version("1.0.0"),
54
+ toggle(name("verbose")),
55
+ routes(
56
+ 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
+ ),
68
+ ),
69
+ ),
70
+ );
71
+ ```
72
+
73
+ That definition makes these entry points—and no others—reachable:
74
+
75
+ ```text
76
+ HELP / VERSION / EXECUTE /
77
+ HELP /serve EXECUTE /serve
78
+ HELP /database
79
+ HELP /database/clean EXECUTE /database/clean
80
+ ```
81
+
82
+ The root name identifies the executable; it is not repeated in route IDs.
83
+ `simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`.
84
+
85
+ ## Route an intent
86
+
87
+ `parse()` returns a discriminated union of the reachable entry points. Dispatch
88
+ can stay flat even when the route tree is deep:
89
+
90
+ ```ts
91
+ import process from "node:process";
92
+ import { parse, printErrors, printHelp, printVersion } from "@bomb.sh/router";
93
+ import { app } from "./app.ts";
94
+
95
+ const result = parse(app, { argv: process.argv.slice(2) });
96
+
97
+ if (!result.ok) {
98
+ console.error(printErrors(result));
99
+ process.exit(1);
100
+ }
101
+
102
+ switch (result.method) {
103
+ case "help":
104
+ console.log(printHelp(result));
105
+ break;
106
+
107
+ case "version":
108
+ console.log(printVersion(result));
109
+ break;
110
+
111
+ case "execute":
112
+ switch (result.route) {
113
+ case "/":
114
+ result.model.verbose; // boolean
115
+ break;
116
+
117
+ case "/serve":
118
+ result.model.port; // number
119
+ result.models["/"].verbose; // boolean
120
+ break;
121
+
122
+ case "/database/clean":
123
+ result.model.dryRun; // boolean
124
+ result.models["/"]; // { verbose: boolean }
125
+ result.models["/database"]; // {}
126
+ break;
127
+ }
128
+ }
129
+ ```
130
+
131
+ An execute intent has two views of configuration:
132
+
133
+ - `model` is owned by the selected route.
134
+ - `models` contains the statically typed model for every route along the
135
+ selected path. Sibling routes are absent from both the value and its type.
136
+
137
+ | Invocation | Intent and model |
138
+ | ------------------------------------- | ------------------------------------------------------------------- |
139
+ | `simulacrum --verbose` | `EXECUTE /` with `{ verbose: true }` |
140
+ | `simulacrum serve --port 4100` | `EXECUTE /serve` with `{ port: 4100 }` |
141
+ | `simulacrum database clean --dry-run` | `EXECUTE /database/clean` with `{ dryRun: true }` |
142
+ | `simulacrum --help database clean` | `HELP /database/clean`; controls target the deepest selected route |
143
+ | `simulacrum database` | `method-not-allowed`; `/database` does not support execution |
144
+ | `simulacrum serve --port nope` | `unprocessable-content`; invalid data never reaches the application |
145
+
146
+ Command literals are routing tokens, not positional arguments. As route segments
147
+ become discoverable, `@bomb.sh/router` scopes parameter binding to the segment
148
+ that owns each token. This makes identical option names on parent and child
149
+ routes unambiguous.
150
+
151
+ ## Bind route parameters from multiple sources
152
+
153
+ CLI arguments are only one source. `@bomb.sh/router` can bind JavaScript values
154
+ and flat environment records onto the same route-local models:
155
+
156
+ ```ts
157
+ const result = parse(app, {
158
+ argv: process.argv.slice(2),
159
+ values: [{
160
+ name: "config.json",
161
+ value: { serve: { port: 4200 } },
162
+ }],
163
+ envs: [{
164
+ name: "process",
165
+ value: process.env,
166
+ }],
167
+ });
168
+ ```
169
+
170
+ For `/serve`, `serve.port` and `SERVE_PORT` both address its `port` parameter.
171
+ CLI text and environment text are decoded; JavaScript values are used directly.
172
+ The resulting value is then validated by its
173
+ [Standard Schema](https://standardschema.dev/) schema. Zod, ArkType, Valibot,
174
+ and other conforming libraries work without adapters.
175
+
176
+ Source precedence is explicit:
177
+
178
+ ```text
179
+ CLI → environment → JavaScript values → schema default
180
+ ```
181
+
182
+ ## Pause without surrendering the type system
183
+
184
+ Sometimes the route cannot be fully configured, or even fully discovered, until
185
+ the application performs I/O. `@bomb.sh/router` can pause at a typed checkpoint
186
+ and resume with the result.
187
+
188
+ Dynamic phases serve two common cases:
189
+
190
+ - Load a configuration file, then use its contents as value sources for later
191
+ parameters.
192
+ - Load plugins, then extend the route graph with their options and routes.
193
+
194
+ `checkpoint()` is the configuration-file convenience; `dynamic()` is the general
195
+ route-extension mechanism. Both keep I/O in the application while preserving the
196
+ exact type of what parsing can produce next.
197
+
198
+ ### Help and version cross checkpoints
199
+
200
+ `--help` and `--version` request methods; they do not settle an intent or bypass
201
+ parsing. `@bomb.sh/router` cannot produce either intent until it knows the
202
+ deepest selected route. A dynamic phase may introduce that route, its options,
203
+ or its version.
204
+
205
+ The driver must therefore resume every increment until parsing returns an
206
+ intent—even when the arguments contain `--help` or `--version`. This applies
207
+ recursively when one continuation exposes another increment.
208
+
209
+ ```text
210
+ app --config app.json auth0 --help
211
+ → bind the configuration phase
212
+ → load configuration and resume
213
+ → discover /auth0
214
+ → HELP /auth0
215
+ ```
216
+
217
+ Help and version are not escape hatches around configuration loading. Do not
218
+ inspect `argv` to skip a checkpoint. A phase may be required by `HELP`,
219
+ `VERSION`, or `EXECUTE`, so its driver work must be safe for all three: return
220
+ loading and validation failures as `Result` issues, avoid command side effects,
221
+ and defer execution until an `EXECUTE` intent. If discovery fails, report that
222
+ failure rather than printing incomplete help for an unresolved route graph.
223
+ Routes without dynamic phases still resolve directly; the rule is to stop only
224
+ at an intent or failure, never merely because the arguments look informational.
225
+
226
+ ```ts
227
+ import process from "node:process";
228
+ import {
229
+ checkpoint,
230
+ command,
231
+ name,
232
+ option,
233
+ parse,
234
+ printErrors,
235
+ printHelp,
236
+ printVersion,
237
+ type Result,
238
+ schema,
239
+ type ValueSource,
240
+ version,
241
+ } from "@bomb.sh/router";
242
+ import * as z from "zod";
243
+
244
+ const app = command(
245
+ name("server"),
246
+ version("1.0.0"),
247
+ option(name("config"), schema(z.string())),
248
+ checkpoint(),
249
+ option(name("port"), schema(z.number())),
250
+ );
251
+
252
+ const step = parse(app, { argv: process.argv.slice(2) });
253
+
254
+ if (!step.ok) {
255
+ console.error(printErrors(step));
256
+ process.exit(1);
257
+ }
258
+
259
+ step.model.config; // string—the model resolved before the checkpoint
260
+
261
+ const loaded = await load(step.model.config);
262
+ const result = step.resume(loaded);
263
+
264
+ if (!result.ok) {
265
+ console.error(printErrors(result));
266
+ process.exit(1);
267
+ }
268
+
269
+ switch (result.method) {
270
+ case "help":
271
+ console.log(printHelp(result));
272
+ break;
273
+
274
+ case "version":
275
+ console.log(printVersion(result));
276
+ break;
277
+
278
+ case "execute":
279
+ result.model; // { config: string; port: number }
280
+ break;
281
+ }
282
+
283
+ declare function load(path: string): Promise<Result<ValueSource[]>>;
284
+ ```
285
+
286
+ The parser remains synchronous and performs no I/O. The caller loads the file
287
+ and resumes with a `Result`; loader failures enter the ordinary issue path.
288
+ Unconsumed CLI input survives the pause, so a later `--port 5000` can override
289
+ the value loaded from the file.
290
+
291
+ The same phase mechanism can add options or routes from runtime data. Parsing
292
+ then continues against the expanded route graph, and the continuation type
293
+ describes the entry points that can appear next.
@@ -0,0 +1,11 @@
1
+ declare global {
2
+ interface Object {
3
+ /**
4
+ * Determines whether an object has a property with the specified name.
5
+ * @param o An object.
6
+ * @param v A property name.
7
+ */
8
+ hasOwn(o: object, v: PropertyKey): boolean;
9
+ }
10
+ }
11
+ export {};
@@ -0,0 +1,15 @@
1
+ // https://github.com/tc39/proposal-accessible-object-hasownproperty/blob/main/polyfill.js
2
+ if (!Object.hasOwn) {
3
+ Object.defineProperty(Object, "hasOwn", {
4
+ value: function (object, property) {
5
+ if (object == null) {
6
+ throw new TypeError("Cannot convert undefined or null to object");
7
+ }
8
+ return Object.prototype.hasOwnProperty.call(Object(object), property);
9
+ },
10
+ configurable: true,
11
+ enumerable: false,
12
+ writable: true,
13
+ });
14
+ }
15
+ export {};
@@ -0,0 +1,7 @@
1
+ import { type Param, type ParamModel } from "./param.js";
2
+ import { type Check, type Fold, type ModelElement, type Unary } from "./pipeline.js";
3
+ import type { Definition } from "./types.js";
4
+ export declare function argument<const N extends string, const E extends readonly Unary[]>(named: Definition<N>, ...elements: E & Check<Zero<N>, E>): ElementOf<N, Fold<Zero<N>, E>>;
5
+ type Zero<N extends string> = Param<N, unknown, "one">;
6
+ type ElementOf<N extends string, P> = P extends Param<N, infer Model, infer _Cardinality> ? ModelElement<ParamModel<N, Model>> : never;
7
+ export {};
@@ -0,0 +1,66 @@
1
+ import { param } from "./param.js";
2
+ import { brand, } from "./pipeline.js";
3
+ export function argument(named, ...elements) {
4
+ const added = elements.reduce((value, element) => element(value), param(named, positional));
5
+ return brand((route) => {
6
+ let phases = [...route.phases];
7
+ let phase = phases.pop();
8
+ phases.push({
9
+ ...phase,
10
+ model: {
11
+ params: {
12
+ ...phase.model.params,
13
+ [added.name]: added,
14
+ },
15
+ steps: phase.model.steps.concat((current, bindings) => ({
16
+ ok: true,
17
+ value: {
18
+ ...current,
19
+ [added.name]: bindings[added.name],
20
+ },
21
+ })),
22
+ },
23
+ });
24
+ return {
25
+ ...route,
26
+ phases,
27
+ };
28
+ });
29
+ }
30
+ function positional(param) {
31
+ return {
32
+ ...param,
33
+ cli: {
34
+ read,
35
+ syntax: {
36
+ type: "argument",
37
+ label: `<${param.name.toUpperCase()}>`,
38
+ },
39
+ },
40
+ };
41
+ }
42
+ const read = (tokens) => {
43
+ let claim = tokens.claimOne((token) => token.type === "word");
44
+ let [word] = claim.tokens;
45
+ return word
46
+ ? {
47
+ claim,
48
+ result: {
49
+ ok: true,
50
+ value: { exists: true, value: word.text },
51
+ issues: [],
52
+ },
53
+ }
54
+ : nothing(tokens);
55
+ };
56
+ function nothing(tokens) {
57
+ let claim = tokens.claimAll(() => false);
58
+ return {
59
+ claim,
60
+ result: {
61
+ ok: true,
62
+ value: { exists: false },
63
+ issues: [],
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,41 @@
1
+ import { type Maybe } from "./maybe.js";
2
+ import type { AnyParam, Param } from "./param.js";
3
+ import type { Symbol } from "./read.js";
4
+ import type { Rest } from "./rest.js";
5
+ import type { Result } from "./result.js";
6
+ import type { TokenInput, TokenRange } from "./tokenizer.js";
7
+ import type { AnyPhase, Issue, OutputOf, Path } from "./types.js";
8
+ export interface Binding<T> {
9
+ readonly rest: Rest;
10
+ readonly result: Result<T>;
11
+ }
12
+ export interface PhaseBinding {
13
+ readonly rest: Rest;
14
+ readonly model: Record<string, unknown>;
15
+ readonly issues: Issue[];
16
+ readonly valid: boolean;
17
+ }
18
+ export interface PhaseSegment {
19
+ readonly range: TokenRange;
20
+ readonly path: Path;
21
+ }
22
+ export declare function fromCLI<P extends Param<string, unknown, "one">>(options: {
23
+ readonly param: P;
24
+ readonly view: TokenInput<Symbol>;
25
+ readonly rest: Rest;
26
+ }): Maybe<Binding<OutputOf<P["schema"]>>>;
27
+ export declare function fromValues<P extends AnyParam>(options: {
28
+ readonly param: P;
29
+ readonly route: Path;
30
+ readonly rest: Rest;
31
+ }): Maybe<Binding<OutputOf<P["schema"]>>>;
32
+ export declare function fromEnv<P extends AnyParam>(options: {
33
+ readonly param: P;
34
+ readonly route: Path;
35
+ readonly rest: Rest;
36
+ }): Maybe<Binding<OutputOf<P["schema"]>>>;
37
+ export declare function bindPhase(options: {
38
+ readonly phase: AnyPhase;
39
+ readonly segment: PhaseSegment;
40
+ readonly rest: Rest;
41
+ }): PhaseBinding;