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
2,852 changes: 1,785 additions & 1,067 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
51 changes: 51 additions & 0 deletions packages/routing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# @feathersjs/routing

Express and Koa compatible routers for Feathers applications.

## 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/*', 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/*', staticService)
```

## Features

- Express and Koa routing compatibility
- Named parameters (`:id`)
- Wildcards (`*`)
- 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"
}
}
61 changes: 61 additions & 0 deletions packages/routing/src/base-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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'])
})
})
100 changes: 100 additions & 0 deletions packages/routing/src/base-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 options: RouterOptions

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

lookup(path: string): LookupResult<T> | null {
const normalizedPath = normalizePath(path)

for (const route of this.routes) {
const flags = this.caseSensitive ? '' : 'i'
const testRegex = new RegExp(route.regexp.source, flags)

const match = testRegex.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.routes.find((route) => route.originalPath === 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.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.routes.splice(index, 1)
}
}
}
Loading
Loading