@bronti/wooter 0.0.0-stage → 6.0.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/DOC.md ADDED
@@ -0,0 +1,289 @@
1
+ Wooter routes Fetch API `Request`s to typed handlers. Paths infer parameters, middleware adds typed state, and handlers answer
2
+ through `resp()` or `json()`.
3
+
4
+ ### Deno
5
+
6
+ ```bash
7
+ deno add @bronti/wooter
8
+
9
+ JSR from npm tooling
10
+ npx jsr add @bronti/wooter
11
+ ```
12
+
13
+ ```ts
14
+ import { p, Wooter } from "@bronti/wooter";
15
+
16
+ const app = new Wooter();
17
+
18
+ app.route(p.path("hello"), "GET", ({ resp }) => {
19
+ resp("Hello, Wooter!");
20
+ });
21
+
22
+ export default app;
23
+ ```
24
+
25
+ Response-first lifecycle
26
+
27
+ A handler that exits without calling `⁠resp()`⁠ or `json()`⁠ fails with `HandlerDidntRespondError`⁠. Calling `⁠resp()`⁠ twice fails too.
28
+
29
+ Routes and parameters
30
+
31
+ Build paths with `⁠p.path()`⁠. A bare string is literal. `⁠p.param()`⁠ captures a segment, and a matcher parses it while preserving
32
+ the inferred type.
33
+
34
+ ```ts
35
+ app.route(
36
+ p.path("users", p.param("id", p.num)),
37
+ "GET",
38
+ async ({ params, json }) => {
39
+ const id = params.get("id"); // number
40
+
41
+ const user = await db.getUser(id);
42
+
43
+ json(user);
44
+ },
45
+ );
46
+ ```
47
+
48
+ Path helpers
49
+
50
+ - Root: `⁠p.path()⁠`
51
+
52
+ - String param: `⁠p.param("slug")⁠`
53
+
54
+ - Number param: `⁠p.param("id", p.num)⁠`
55
+
56
+ - Remaining segments: `⁠p.param("rest", p.rest)⁠`
57
+
58
+ - Optional matcher: `⁠p.optional(matcher)⁠`
59
+
60
+ - Repeated matcher: `⁠p.multiple(matcher)⁠`
61
+
62
+ One path can also use a method map:
63
+
64
+ ```ts
65
+ app.route(p.path("items"), {
66
+ GET: ({ json }) => json(listItems()),
67
+
68
+ POST: async ({ request, json }) => {
69
+ const item = await request.json();
70
+
71
+ json(await createItem(item), { status: 201 });
72
+ },
73
+ });
74
+ ```
75
+
76
+ Middleware
77
+
78
+ `⁠use()`⁠ adds middleware in registration order. Middleware can enrich downstream state, short-circuit with a response, or inspect
79
+ the response coming back up.import { middleware, p, Wooter } from "@bronti/wooter"
80
+
81
+ ```ts
82
+ const auth = middleware<{ user: User }>(async ({ request, forward, resp }) => {
83
+ const user = await authenticate(request);
84
+
85
+ if (!user) return resp("Unauthorized", { status: 401 });
86
+
87
+ await forward({ user });
88
+ });
89
+
90
+ const app = new Wooter()
91
+ .use(auth)
92
+ .route(p.path("me"), "GET", ({ state, json }) => {
93
+ json(state.user);
94
+ });
95
+ ```
96
+
97
+ Middleware tools
98
+
99
+ - forward(state) Runs the rest of the chain and forwards its response unchanged.
100
+
101
+ - next(state) Runs downstream and returns its `⁠Response`⁠ so middleware can inspect or replace it.
102
+
103
+ - tryNext(state) Captures downstream success or failure in a `Result` instead of throwing.
104
+
105
+ - tryForward(state) Captures the `⁠Result`⁠ and forwards a successful response.
106
+
107
+ The green path
108
+
109
+ Keep route handlers focused on successful application work. Do not wrap every database call in a local ⁠try/catch⁠ just to translate
110
+ failures into HTTP. Let the operation throw. Put the translation policy once in middleware, keyed by specific error classes.
111
+
112
+ Handler: business flow only
113
+
114
+ ```ts
115
+ app.route(
116
+ p.path("books", p.param("id", p.num)),
117
+ "GET",
118
+ async ({ params, json }) => {
119
+ const book = await db.getBook(params.get("id"));
120
+
121
+ json(book);
122
+ },
123
+ );
124
+ ```
125
+
126
+ Middleware: errors become responses
127
+
128
+ ```ts
129
+ const errors = middleware(async ({ tryNext, resp }) => {
130
+ const result = await tryNext({});
131
+
132
+ result.match(
133
+ (response) => resp(response),
134
+ (error) => {
135
+ if (error instanceof BookNotFoundError) {
136
+ return resp("Book not found", { status: 404 });
137
+ }
138
+
139
+ if (error instanceof DatabaseUnavailableError) {
140
+ return resp("Try again later", { status: 503 });
141
+ }
142
+
143
+ throw error;
144
+ },
145
+ );
146
+ });
147
+
148
+ const app = new Wooter().use(errors);
149
+ ```
150
+
151
+ Why this stays green
152
+
153
+ The handler reads top to bottom as the successful request. Middleware owns cross-cutting HTTP policy. Unknown errors are rethrown
154
+ rather than disguised as expected failures.
155
+
156
+ Throw is part of the design
157
+
158
+ Wooter deliberately uses thrown values as control flow through the request lifecycle. A route can let an application error throw;
159
+ the middleware chain can capture it with `⁠tryNext()`⁠ and decide which errors become responses.
160
+
161
+ ⁠`safeExit()`⁠ makes that intent explicit for framework control flow. After code has already responded, it throws Wooter’s internal
162
+ `⁠ControlFlowBreak⁠` sentinel to stop the rest of the handler without surfacing a failure.
163
+
164
+ ```ts
165
+ const requireJson = middleware<{ body: unknown }>(
166
+ async ({ request, resp, forward, safeExit }) => {
167
+ let body: unknown;
168
+
169
+ try {
170
+ body = await request.json();
171
+ } catch {
172
+ resp("Invalid JSON", { status: 400 });
173
+
174
+ return safeExit(); // throws ControlFlowBreak intentionally
175
+ }
176
+
177
+ await forward({ body });
178
+ },
179
+ );
180
+ ```
181
+
182
+ Respond, then stop
183
+
184
+ `⁠safeExit()`⁠ is for intentional exit after a response exists. Ordinary application errors should keep their real identity so error
185
+ middleware can match them.
186
+
187
+ Stay on the handler execution
188
+
189
+ Keep asynchronous route work attached to the handler’s returned promise, usually by using `⁠await⁠`. Wooter requires a response
190
+ before handler execution stops, and attached work lets thrown errors travel through the middleware chain instead of escaping later
191
+ as stray errors or crashes.
192
+
193
+ Detached: execution ends too early
194
+
195
+ ```ts
196
+ app.route(path, "GET", ({ json }) => {
197
+ setTimeout(async () => {
198
+ const book = await db.getBook(1);
199
+
200
+ json(book);
201
+ }, 10);
202
+ // function exits here, but json() is called 10 milliseconds after
203
+ });
204
+ ```
205
+
206
+ Inside out: await the timer
207
+
208
+ ```ts
209
+ const delay = (ms: number) =>
210
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
211
+
212
+ app.route(path, "GET", async ({ json }) => {
213
+ await delay(10);
214
+
215
+ const book = await db.getBook(1);
216
+
217
+ json(book);
218
+ });
219
+ ```
220
+
221
+ The rule of thumb
222
+
223
+ If an API wants a callback such as `⁠setTimeout()`⁠, flip it inside out: wrap it in a promise, await that promise inside the
224
+ handler, then keep going. Response timing and error propagation stay inside one execution.
225
+
226
+ Response tools
227
+
228
+ - Text or body: `⁠resp(body, init?)⁠`
229
+
230
+ - Full response: `⁠resp(new Response(...))⁠`
231
+
232
+ - JSON: `⁠json(data, init?)⁠`
233
+
234
+ - Error helper: `⁠makeError(status, message?)⁠`
235
+
236
+ - Redirect helper: `⁠makeRedirect(location, init?)⁠`
237
+
238
+ - Intentional stop: `⁠safeExit()⁠`
239
+
240
+ `safeExit()`⁠ is useful after middleware has already sent a response but nested helper code must stop immediately.
241
+
242
+ A practical shape
243
+
244
+ ```ts
245
+ import { middleware, p, Wooter } from "@bronti/wooter";
246
+
247
+ const errors = middleware(async ({ tryNext, resp }) => {
248
+ const result = await tryNext({});
249
+
250
+ result.match(
251
+ (response) => resp(response),
252
+ (error) => {
253
+ if (error instanceof NotFoundError) {
254
+ return resp("Not found", { status: 404 });
255
+ }
256
+
257
+ throw error;
258
+ },
259
+ );
260
+ });
261
+
262
+ const app = new Wooter()
263
+ .use(errors)
264
+ .route(
265
+ p.path("books", p.param("id", p.num)),
266
+ "GET",
267
+ async ({ params, json }) => {
268
+ json(await db.getBook(params.get("id")));
269
+ },
270
+ );
271
+
272
+ export default app;
273
+ ```
274
+
275
+ One Fetch entrypoint, any runtime
276
+
277
+ Wooter runs anywhere JavaScript provides the standard `⁠Request⁠` and `⁠Response`⁠ classes, including browsers, Deno, Node.js, Bun,
278
+ Workers and other Fetch-compatible runtimes.
279
+
280
+ Its runtime entrypoint is `⁠wooter.fetch(request)`⁠. Give it a `⁠Request`⁠; it returns a `⁠Promise<Response>`⁠. The host decides how
281
+ requests reach that method.
282
+
283
+ ```ts
284
+ const wooter = new Wooter().route(p.path("hello"), "GET", ({ resp }) => {
285
+ resp("Hello");
286
+ });
287
+
288
+ const response = await wooter.fetch(new Request("https://example.test/hello"));
289
+ ```
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 @brontidev
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @bronti/wooter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![JSR](https://jsr.io/badges/@bronti/wooter)](https://jsr.io/@bronti/wooter)
4
+ [![JSR Score](https://jsr.io/badges/@bronti/wooter/score)](https://jsr.io/@bronti/wooter)
5
+
6
+ A **fetch-native, type-safe HTTP router** for JavaScript/TypeScript with **explicit middleware composition** and **zero runtime
7
+ dependencies**.
8
+
9
+ ```ts
10
+ import { p, Wooter } from "@bronti/wooter";
11
+
12
+ const app = new Wooter()
13
+ .use(cors)
14
+ .use(auth)
15
+ .route(
16
+ p.path("users", p.param("id", p.num)),
17
+ "GET",
18
+ async ({ params, resp }) => {
19
+ resp(Response.json({ userId: params.get("id") }));
20
+ },
21
+ );
22
+
23
+ export default app.fetch;
24
+ ```
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@bronti/wooter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "6.0.0",
4
+ "license": "MIT",
5
+ "exports": {
6
+ "./*": "./src/export/*"
7
+ },
8
+ "scripts": {
9
+ "fmt:check": "bun fmt --check",
10
+ "fmt:fix": "bun fmt"
11
+ },
12
+ "dependencies": {
13
+ "@bronti/robust": "npm:@jsr/bronti__robust@0.0.3-beta"
14
+ }
15
+ }
package/src/Graph.ts ADDED
@@ -0,0 +1,231 @@
1
+ import { match } from "./path/match.ts";
2
+ import type { Path } from "./path/types.ts";
3
+ import type { Methods, RouteHandler, State } from "./types/types.ts";
4
+ import type { InternalHandler } from "./RouteContext.ts";
5
+ import MiddlewareContext, {
6
+ type MiddlewareHandler,
7
+ } from "./MiddlewareContext.ts";
8
+ import RouteContext from "./RouteContext.ts";
9
+
10
+ /**
11
+ * Method definition accepted by `route` overloads.
12
+ *
13
+ * @internal
14
+ */
15
+ export type MethodDefinitionInput =
16
+ Methods | Uppercase<string> | Methods[] | Uppercase<string>[] | "*";
17
+
18
+ /**
19
+ * Map of HTTP methods to handlers for a route.
20
+ *
21
+ * @internal
22
+ */
23
+ export type MethodDefinitions<
24
+ TParams extends Record<string, unknown>,
25
+ TState extends State | undefined = undefined,
26
+ > = Partial<
27
+ Record<
28
+ Methods,
29
+ RouteHandler<TParams, TState extends undefined ? State : TState>
30
+ >
31
+ > &
32
+ Record<
33
+ Uppercase<string>,
34
+ RouteHandler<TParams, TState extends undefined ? State : TState>
35
+ >;
36
+
37
+ enum NodeType {
38
+ MethodsToHandlers,
39
+ AnyMethod,
40
+ HandlerWithMethods,
41
+ }
42
+
43
+ export type Node = (
44
+ | {
45
+ t: NodeType.MethodsToHandlers;
46
+ handlers: Map<string, RouteHandler>;
47
+ }
48
+ | { t: NodeType.AnyMethod; handler: RouteHandler }
49
+ | {
50
+ t: NodeType.HandlerWithMethods;
51
+ handler: RouteHandler;
52
+ methods: Set<string>;
53
+ }
54
+ ) & { path: Path };
55
+
56
+ export type Namespace = {
57
+ path: Path;
58
+ graph: Graph;
59
+ };
60
+
61
+ export class Graph {
62
+ private nodes = new Set<Node>();
63
+ private middleware = new Set<MiddlewareHandler>();
64
+ private namespaces = new Set<Namespace>();
65
+
66
+ constructor(private basePath?: Path) {}
67
+
68
+ addMiddleware(handler: MiddlewareHandler<any, any, any>) {
69
+ this.middleware.add(handler);
70
+ }
71
+
72
+ addNamespace(path: Path, graph: Graph) {
73
+ this.namespaces.add({
74
+ graph,
75
+ path,
76
+ });
77
+ }
78
+
79
+ addNode(node: Node) {
80
+ this.nodes.add(node);
81
+ }
82
+
83
+ addRoute_wildcardMethod(path: Path, handler: RouteHandler<any, any>) {
84
+ this.addNode({
85
+ t: NodeType.AnyMethod,
86
+ path,
87
+ handler,
88
+ });
89
+ }
90
+
91
+ addRoute_withMethodSet(
92
+ path: Path,
93
+ handler: RouteHandler<any, any>,
94
+ methods: Set<string>,
95
+ ) {
96
+ this.addNode({
97
+ t: NodeType.HandlerWithMethods,
98
+ path,
99
+ handler,
100
+ methods,
101
+ });
102
+ }
103
+
104
+ addRoute_withMethodMap(
105
+ path: Path,
106
+ handlers: Record<string, RouteHandler<any, any>>,
107
+ ) {
108
+ this.addNode({
109
+ t: NodeType.MethodsToHandlers,
110
+ handlers: new Map(
111
+ Object.entries(handlers).map(([k, v]) => [k.toUpperCase(), v]),
112
+ ),
113
+ path,
114
+ });
115
+ }
116
+
117
+ protected internalGetHandler(
118
+ path: string[],
119
+ i: number,
120
+ method: string,
121
+ outParams: Record<string, any>,
122
+ outMiddleware: MiddlewareHandler[],
123
+ ): RouteHandler | undefined {
124
+ for (const namespace of this.namespaces) {
125
+ const namespaceResult = match(path, i, namespace.path, false);
126
+ if (!namespaceResult.match) continue;
127
+
128
+ const middlewareStartIndex = outMiddleware.length;
129
+ for (const mw of this.middleware) outMiddleware.push(mw);
130
+ Object.assign(outParams, namespaceResult.params);
131
+
132
+ const handler = namespace.graph.internalGetHandler(
133
+ path,
134
+ namespaceResult.stopped_at,
135
+ method,
136
+ outParams,
137
+ outMiddleware,
138
+ );
139
+ if (handler) return handler;
140
+
141
+ outMiddleware.length = middlewareStartIndex;
142
+ continue;
143
+ }
144
+
145
+ for (const node of this.nodes) {
146
+ let handler: RouteHandler | undefined;
147
+ if (node.t === NodeType.MethodsToHandlers) {
148
+ const _handler = node.handlers.get(method);
149
+ if (!_handler) continue;
150
+ handler = _handler;
151
+ } else if (node.t === NodeType.AnyMethod) {
152
+ handler = node.handler;
153
+ } else if (node.t === NodeType.HandlerWithMethods) {
154
+ if (!node.methods.has(method)) continue;
155
+ handler = node.handler;
156
+ } else {
157
+ throw new TypeError("Invalid configuration");
158
+ }
159
+ const result = match(path, i, node.path, true);
160
+ if (!result.match) continue;
161
+
162
+ Object.assign(outParams, result.params);
163
+ for (const mw of this.middleware) outMiddleware.push(mw);
164
+
165
+ return handler;
166
+ }
167
+ }
168
+
169
+ getHandler(pathname: string, method: string): InternalHandler | undefined {
170
+ method = method.toUpperCase();
171
+ const path = pathname.split("/");
172
+ if (path[0] == "") path.shift();
173
+ if (path[path.length - 1] == "") path.pop();
174
+
175
+ let i = 0;
176
+
177
+ const params: Record<string, any> = {};
178
+ const middleware: MiddlewareHandler[] = [];
179
+
180
+ if (this.basePath) {
181
+ const result = match(path, i, this.basePath, false);
182
+ if (!result.match) return;
183
+ Object.assign(params, result.params);
184
+ i = result.stopped_at;
185
+ }
186
+
187
+ const handler = this.internalGetHandler(path, i, method, {}, []);
188
+ if (!handler) return;
189
+ return Graph.compose(handler, params, middleware);
190
+ }
191
+
192
+ /**
193
+ * Composes middleware and route handler into an executable internal handler.
194
+ *
195
+ * @param handler Final route handler.
196
+ * @param params Route params.
197
+ * @param middlewareSet Middleware chain.
198
+ * @returns Internal handler.
199
+ */
200
+ static compose(
201
+ handler: RouteHandler,
202
+ params: Record<string, unknown>,
203
+ middlewareSet: readonly MiddlewareHandler[],
204
+ ): InternalHandler {
205
+ const middleware = middlewareSet.values();
206
+ return (state, req, reporter) => {
207
+ const createNext = (): InternalHandler => (nextState, req, reporter) => {
208
+ Object.assign(state, nextState);
209
+ const { done, value: currentMiddleware } = middleware.next();
210
+ let currentHandler: InternalHandler;
211
+ if (done) {
212
+ currentHandler = RouteContext.useRouteHandler(
213
+ handler,
214
+ params,
215
+ reporter,
216
+ );
217
+ } else {
218
+ currentHandler = MiddlewareContext.useMiddlewareHandler(
219
+ currentMiddleware,
220
+ params,
221
+ createNext(),
222
+ reporter,
223
+ );
224
+ }
225
+
226
+ return currentHandler(state, req, reporter);
227
+ };
228
+ return createNext()(state, req, reporter);
229
+ };
230
+ }
231
+ }