Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,092 changes: 1,752 additions & 1,340 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/feathers/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
ApplicationHookOptions
} from './declarations.js'
import { enableHooks } from './hooks.js'
import { Router } from './router.js'
import { Router, RouterInterface } from './router.js'
import { Channel } from './channel/base.js'
import { CombinedChannel } from './channel/combined.js'
import { channelServiceMixin, Event, Publisher, PUBLISHERS, ALL_EVENTS, CHANNELS } from './channel/mixin.js'
Expand All @@ -35,7 +35,7 @@ export class Feathers<Services, Settings>
settings: Settings = {} as Settings
mixins: ServiceMixin<Application<Services, Settings>>[] = [hookMixin, eventMixin]
version: string = version
routes: Router = new Router()
routes: RouterInterface = new Router()
_isSetup = false

protected registerHooks: (this: any, allHooks: any) => any
Expand Down
4 changes: 2 additions & 2 deletions packages/feathers/src/declarations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from 'events'
import type { Router } from './router.js'
import type { RouterInterface } from './router.js'
import { NextFunction, HookContext as BaseHookContext } from './hooks/index.js'

type SelfOrArray<S> = S | S[]
Expand Down Expand Up @@ -241,7 +241,7 @@ export interface FeathersApplication<Services = any, Settings = any> {
/**
* The application routing mechanism
*/
routes: Router<{
routes: RouterInterface<{
service: Service
params?: { [key: string]: any }
}>
Expand Down
26 changes: 24 additions & 2 deletions packages/feathers/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { stripSlashes } from './commons.js'

export interface LookupData {
params: { [key: string]: string }
params: { [key: string]: string | string[] }
}

export interface LookupResult<T> extends LookupData {
data?: T
}

export interface RouterInterface<T = any> {
/**
* Look up a route by path and return the matched data and parameters
*/
lookup(path: string): LookupResult<T> | null

/**
* Insert a new route with associated data
*/
insert(path: string, data: T): void

/**
* Remove a route by path
*/
remove(path: string): void

/**
* Whether route matching is case sensitive
*/
caseSensitive: boolean
}

export class RouteNode<T = any> {
data?: T
children: { [key: string]: RouteNode } = {}
Expand Down Expand Up @@ -115,7 +137,7 @@ export class RouteNode<T = any> {
}
}

export class Router<T = any> {
export class Router<T = any> implements RouterInterface<T> {
public caseSensitive = true

constructor(public root: RouteNode<T> = new RouteNode<T>('', 0)) {}
Expand Down
68 changes: 68 additions & 0 deletions packages/routing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# @feathersjs/routing

Express and Koa compatible routers for Feathers applications. Uses `path-to-regexp` for full compatibility with Express and Koa routing patterns.

## Installation

```bash
npm install @feathersjs/routing
```

## Usage

### Express Router

```ts
import { feathers } from 'feathers'
import { ExpressRouter } from '@feathersjs/routing'

const app = feathers()
app.routes = new ExpressRouter()

// Now supports Express routing patterns
app.use('/users/:id', userService)
app.use('/docs/*path', docsService)
```

### Koa Router

```ts
import { feathers } from 'feathers'
import { KoaRouter } from '@feathersjs/routing'

const app = feathers()
app.routes = new KoaRouter()

// Now supports Koa routing patterns
app.use('/users/:id', userService)
app.use('/static/*path', staticService)
```

### Custom Options

Both routers accept options to customize behavior:

```ts
import { ExpressRouter } from '@feathersjs/routing'

// Override defaults
const router = new ExpressRouter({
caseSensitive: true, // default: false for Express, true for Koa
trailing: true // default: false for Express, true for Koa
})
```

## Features

- Express and Koa routing compatibility via `path-to-regexp`
- Named parameters (`:id`)
- Wildcards (`*path`)
- Optional parameters (`/users/:id?`)
- Regex constraints (`/users/:id(\\d+)`)
- Repeating parameters (`/files/:path+`)
- Case sensitivity control
- Runtime agnostic (Node.js, Deno, Bun, Cloudflare Workers)

## License

MIT
47 changes: 47 additions & 0 deletions packages/routing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@feathersjs/routing",
"version": "6.0.0-pre.0",
"description": "Express and Koa compatible routers for Feathers applications",
"keywords": [
"feathers",
"routing",
"express",
"koa"
],
"license": "MIT",
"author": "Feathers Team",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js",
"require": "./lib/index.js"
}
},
"files": [
"lib/"
],
"scripts": {
"compile": "shx rm -rf lib/ && tsc",
"test": "vitest run --coverage",
"test:watch": "vitest"
},
"dependencies": {
"feathers": "^6.0.0-pre.0",
"path-to-regexp": "^8.2.0"
},
"devDependencies": {
"vitest": "^3.2.4",
"typescript": "^5.6.2",
"shx": "^0.3.4"
},
"engines": {
"node": ">= 18"
},
"repository": {
"type": "git",
"url": "git+https://github.com/feathersjs/feathers.git",
"directory": "packages/routing"
}
}
71 changes: 71 additions & 0 deletions packages/routing/src/base-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'assert'
import { describe, it } from 'vitest'
import { BaseRouter } from './base-router.js'

// Test implementation of BaseRouter for coverage
class TestRouter extends BaseRouter {
constructor(options = {}) {
super(options)
}
}

describe('BaseRouter', () => {
it('uses default caseSensitive when not provided', () => {
const router = new TestRouter({})
assert.strictEqual(router.caseSensitive, true)
})

it('respects provided caseSensitive option', () => {
const router = new TestRouter({ caseSensitive: false })
assert.strictEqual(router.caseSensitive, false)
})

it('handles wildcard parameter edge cases', () => {
const router = new TestRouter({ caseSensitive: true })

// Test wildcard with minimal path
router.insert('/files/*path', 'file-handler')

const result = router.lookup('/files/single')
assert.ok(result)
assert.deepStrictEqual(result.params['path'], ['single'])
})

it('handles wildcard parameter extraction', () => {
const router = new TestRouter()

router.insert('/docs/*path', 'docs-handler')
const result = router.lookup('/docs/test/file')

assert.ok(result)
assert.deepStrictEqual(result.params['path'], ['test', 'file'])
})

it('handles empty wildcard values', () => {
const router = new TestRouter()

// Create a route with minimal wildcard match
router.insert('/files/*path', 'handler')

// Test with a path that would create an empty capture group
// /files/something should capture ['something'], but let's test edge cases
const result1 = router.lookup('/files/a')
assert.ok(result1)
assert.deepStrictEqual(result1.params['path'], ['a'])

// Test with multiple segments
const result2 = router.lookup('/files/a/b/c')
assert.ok(result2)
assert.deepStrictEqual(result2.params['path'], ['a', 'b', 'c'])
})

it('returns null for non-string paths', () => {
const router = new TestRouter()
router.insert('/users', 'users-handler')

assert.strictEqual(router.lookup(null as any), null)
assert.strictEqual(router.lookup(undefined as any), null)
assert.strictEqual(router.lookup(123 as any), null)
assert.strictEqual(router.lookup({} as any), null)
})
})
104 changes: 104 additions & 0 deletions packages/routing/src/base-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { pathToRegexp, Key } from 'path-to-regexp'
import { RouterInterface, LookupResult } from 'feathers'
import { stripSlashes } from 'feathers/commons'

function normalizePath(path: string): string {
if (!path || path === '/') {
return ''
}
return stripSlashes(path)
}

export interface RouterOptions {
caseSensitive?: boolean
trailing?: boolean
}

export abstract class BaseRouter<T = any> implements RouterInterface<T> {
public caseSensitive: boolean

private routes: Array<{
regexp: RegExp
keys: Key[]
data: T
originalPath: string
}> = []

private pathSet: Set<string> = new Set()
private options: RouterOptions

constructor(options: RouterOptions) {
this.caseSensitive = options.caseSensitive ?? true
this.options = options
}

lookup(path: string): LookupResult<T> | null {
if (typeof path !== 'string') {
return null
}

const normalizedPath = normalizePath(path)

for (const route of this.routes) {
const match = route.regexp.exec(normalizedPath)

if (match) {
const params: { [key: string]: string | string[] } = Object.create(null)

for (let i = 0; i < route.keys.length; i++) {
const key = route.keys[i]
const value = match[i + 1]

if (value !== undefined) {
if (key.type === 'wildcard' || String(key.name).startsWith('*')) {
const paramName = String(key.name).replace(/^\*/, '') || '*'
params[paramName] = value ? value.split('/').filter(Boolean) : []
} else {
params[key.name] = value
}
}
}

return {
data: route.data,
params
}
}
}

return null
}

insert(path: string, data: T): void {
const normalizedPath = normalizePath(path)

if (this.pathSet.has(normalizedPath)) {
throw new Error(`Path ${normalizedPath} already exists`)
}

const { regexp, keys } = pathToRegexp(normalizedPath, {
sensitive: this.caseSensitive,
end: true,
trailing: this.options.trailing,
start: true
})

this.pathSet.add(normalizedPath)
this.routes.push({
regexp,
keys,
data,
originalPath: normalizedPath
})
}

remove(path: string): void {
const normalizedPath = normalizePath(path)
const index = this.routes.findIndex((route) => route.originalPath === normalizedPath)

if (index !== -1) {
this.pathSet.delete(normalizedPath)
this.routes.splice(index, 1)
}
}
}
Loading
Loading