Skip to content

feat: add nested router support with mount() and Router class#396

Open
avoidwork wants to merge 1 commit into
masterfrom
feat/nested-routers
Open

feat: add nested router support with mount() and Router class#396
avoidwork wants to merge 1 commit into
masterfrom
feat/nested-routers

Conversation

@avoidwork
Copy link
Copy Markdown
Owner

@avoidwork avoidwork commented May 23, 2026

Description

Adds nested router capability to Woodland, enabling developers to break up app concerns into modular, hierarchical route trees through `app.mount(path)` and `app.use(path, router)` APIs.

Type of Change

  • New feature (non-breaking change which adds functionality)

Testing

Ran the full test suite before committing:

  • `npm test`: All 379 tests pass (338 original + 41 new), 100% line coverage
  • `npm run build`: Builds successfully without errors

New test files:

  • `tests/unit/nested-routers-use.test.js` — Router class, mounting via `use()`
  • `tests/unit/nested-routers-mount.test.js` — `mount()` method and prefix handling
  • `tests/unit/nested-routers-nested.test.js` — Two-level and three-level nesting
  • `tests/unit/nested-routers-params.test.js` — Parameterized routes through mounts
  • `tests/unit/nested-routers-middleware.test.js` — Prefix middleware isolation
  • `tests/unit/nested-routers-errors.test.js` — Error handler isolation
  • `tests/unit/nested-routers-list.test.js` — `app.list()` with nested routes

Coverage

  • 100% line coverage maintained

Checklist

  • `npm run lint` passes
  • Tests pass with 100% line coverage
  • No forbidden patterns used
  • Conventional Commit style applied

Affected Files

File Lines Changed Summary
`src/constants.js` +2 Added `SUB_ROUTER` marker constant
`src/middleware.js` +51 Router class, `isRouter()`, `flattenRouterRoutes()`
`src/woodland.js` +173 `mount()` method, `Router` class, `use()` routing
`types/woodland.d.ts` +24 Router type definitions
`dist/woodland.{js,cjs}` +445 Built dist files
Tests +452 7 new test files, 41 tests
Design docs +95 Proposal, design, specs, tasks

- Add Router class with chainable route methods (get, post, put, delete, patch, always, etc.)
- Add app.mount(path) to create a composable router under a shared prefix
- Add app.use(path, router) to mount a Router instance with prefix flattening
- Support recursive nested routers (Router within Router within Router...)
- Support parameterized routes (/:id) working through mount chains
- Keep error handlers isolated per Router scope
- Add TypeScript definitions for Router class and new method signatures
- Add 41 unit tests across 7 test files for router functionality
@avoidwork avoidwork self-assigned this May 23, 2026
@augmentcode
Copy link
Copy Markdown

augmentcode Bot commented May 23, 2026

🤖 Augment PR Summary

Summary: This PR introduces nested routing support by adding a composable Router abstraction and a mount/mounting workflow.

Changes:

  • Added SUB_ROUTER marker (and related constants) to support router detection.
  • Implemented Router in src/woodland.js with chainable HTTP method registration.
  • Added Woodland.mount(path) to create a router for modular route definition.
  • Updated Woodland.use(path, router) to detect routers and flatten their routes into the parent registry.
  • Extended middleware registration to treat named capture-group patterns as parameterized routes.
  • Exported Router in build outputs and updated TypeScript declarations.
  • Added a new unit-test suite covering router mounting, nesting, params behavior, and route listing.
  • Added openspec design/proposal/spec documentation for the nested routers feature.

Technical Notes: Nested routers are flattened at mount time by prefixing paths and registering them into the parent middleware map; this preserves the existing regex-based matcher while enabling hierarchical route organization.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 10 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread src/middleware.js
fullPath = path.startsWith(SLASH) ? path : `${SLASH}${path}`;
} else {
const p = path.startsWith(SLASH) || path === "" ? path.substring(INT_1) : path;
fullPath = `${prefix}${SLASH}${p}`;
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/middleware.js:L266: fullPath = ${prefix}/${p}`` will produce double slashes when prefix is `"/"` (e.g. mounting at root yields `//users`), and it also doesn't enforce that `prefix` begins with a leading slash, which can register routes that will never match.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread src/woodland.js
this.#logger.logMiddleware(rpath, fn[fn.length - INT_1]);
if (fn.length === INT_1 && isRouter(fn[INT_0])) {
const router = fn[INT_0];
const prefix = typeof rpath === STRING ? rpath : SLASH;
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/woodland.js:L768: When mounting a Router, prefix falls back to SLASH if rpath isn't a string, which can silently mount at / and ignore the provided first argument if use() is called with an unexpected signature.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread src/woodland.js
* @param {string} path - Mount prefix path
* @returns {Router} Chainable router instance
*/
mount(path) {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/woodland.js:L803: mount(path) stores path on Router.prefix, but that prefix is never used during route registration/flattening (the effective prefix comes from the app.use(rpath, router) call), so mount('/api') doesn't actually compose paths unless the caller repeats the prefix.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread src/woodland.js
* @returns {Router} Returns self for chaining
*/
ignore(fn) {
this.parent.ignore(fn);
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/woodland.js:L833: Router.ignore() assumes this.parent is a Woodland instance with ignore(), but the PR's own tests construct nested routers with new Router(apiRouter, ...), where parent is another Router and this method would throw if used.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread src/woodland.js
}

const pathMap = this.routeMap.get(method);
const last = fn[fn.length - INT_1];
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/woodland.js:L945: #registerPath() allows registering a route with no handlers (empty fn), which later mounts into the middleware registry as a route with zero handlers and can lead to confusing "registered but does nothing" behavior.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread types/woodland.d.ts
mount(path: string): Router;
use(rpath: string | Function, ...fn: Function[]): Woodland;
use(rpath: string, ...fn: (Function | string)[]): Woodland;
use(rpath: string, subApp: Woodland): Woodland;
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types/woodland.d.ts:L85: The overload use(rpath: string, subApp: Woodland) doesn't match the runtime implementation, which only detects/handles Router instances in Woodland.use() (passing a Woodland instance here won't be treated as a nested router).

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread types/woodland.d.ts
export default woodland;

export class Router {
parent: Woodland;
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types/woodland.d.ts:L102: Router.parent is typed as Woodland, but the PR's tests create nested routers with a Router parent; this type mismatch will make that supported pattern hard/impossible to type-check.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

The system SHALL allow mounting a Woodland instance at a given path prefix so that all of its routes are resolved relative to that prefix.

#### Scenario: Mount sub-app at prefix
- **WHEN** a Woodland instance is passed as the second argument to `app.use('/api', subApp)`
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openspec/changes/nested-routers/specs/nested-routers/spec.md:L7: This spec states app.use('/api', subApp) mounts a Woodland instance, but the implementation added in this PR mounts only Router instances; the spec and code should agree on what the supported nested unit is.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread src/constants.js
export const ROUTE_PATTERN = "(/.*)?";
export const MSG_USE_MIDDLEWARE_REQUIRED =
"useMiddleware is required or config.use must be a function";
export const MSG_SUB_APP_REQUIRED = "Expected a Woodland instance for nested routing";
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/constants.js:L156: MSG_SUB_APP_REQUIRED is introduced but not used anywhere in the runtime checks, which makes it easy for invalid nested-routing inputs to fail with less-specific errors (or silently mount at /).

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread coverage.txt
ℹ fileserver.js | 100.00 | 89.36 | 100.00 |
ℹ logger.js | 100.00 | 94.23 | 95.45 |
ℹ middleware.js | 100.00 | 100.00 | 100.00 |
ℹ middleware.js | 99.72 | 96.59 | 100.00 | 263
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coverage.txt:L11: The committed coverage report shows new uncovered lines (e.g. src/middleware.js at 99.72%), which appears to conflict with the repo's stated requirement of 100% line coverage (Rule: AGENTS.md).

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant