Skip to content
Merged
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
11 changes: 1 addition & 10 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,4 @@ jobs:

- name: Publish package to npm
working-directory: packages/nestjs-endpoints
run: |
cat << 'EOF' > .npmrc
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
registry=https://registry.npmjs.org/
always-auth=true
EOF

npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
nodejs 23.9.0
nodejs 24.14.0
pnpm 9.4.0
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 2.2.0 (2026-04-18)

### Features

- **`rootDirectory` accepts `string | string[]`**: An `EndpointsRouterModule` can now scan multiple folders. Each entry is scanned the same way as a single root: any directory encountered that contains a `router.module.*` is auto-discovered as a nested router (at any depth, including the entry itself).
- **Folder-inferred `basePath` for `router.module.ts` files**: When the callsite is a `router.module.ts` and `basePath` is omitted, the `basePath` is inferred from the folder containing the router module — at the top level from `path.basename(definedAtDir)`, and for nested routers from the parent's `basePath` joined with the child's folder relative to the parent's folder. Pass `basePath: ''` to opt out.

## 2.1.0 (2026-04-18)

### Features
Expand Down
2 changes: 1 addition & 1 deletion packages/nestjs-endpoints/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const settings: {
endpoints: {
file: string;
setupFn: (settings: {
rootDirectory: string;
rootDirectories: string[];
basePath: string;
}) => void;
}[];
Expand Down
8 changes: 4 additions & 4 deletions packages/nestjs-endpoints/src/endpoint-fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,10 +376,10 @@ export function endpoint<
class cls {}
const file = getCallsiteFile();
const setupFn = ({
rootDirectory,
rootDirectories,
basePath,
}: {
rootDirectory: string;
rootDirectories: string[];
basePath: string;
}) => {
const { httpPath, httpPathPascalName, httpPathSegments } = (() => {
Expand All @@ -395,7 +395,7 @@ export function endpoint<
httpPathPascalName: getHttpPathPascalName(combined),
};
}
return getEndpointHttpPath(rootDirectory, basePath, file);
return getEndpointHttpPath(rootDirectories, basePath, file);
})();
let outputSchemas: Record<number, Schema | SchemaDef> | null = null;
if (output) {
Expand Down Expand Up @@ -639,7 +639,7 @@ export function endpoint<
setupFn,
});
} else {
setupFn({ rootDirectory: process.cwd(), basePath: '' });
setupFn({ rootDirectories: [process.cwd()], basePath: '' });
}

return cls as any;
Expand Down
163 changes: 124 additions & 39 deletions packages/nestjs-endpoints/src/endpoints-router.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,39 @@ function isMiddlewareOptions(m: unknown): m is RouterMiddlewareOptions {
export class EndpointsRouterModule {
static async register(params?: {
/**
* The root directory to load endpoints from recursively. Relative and absolute
* paths are supported.
* @default The directory of the calling file
* Directory (or directories) this router loads endpoints from. Relative
* paths are resolved against the calling file's directory; absolute
* paths are used as-is.
*
* Each entry is scanned recursively. Any directory encountered that
* contains a `router.module.*` file is auto-discovered as a nested
* router (regardless of depth) — the parent imports it and does not
* scan inside it. Directories without a `router.module.*` are scanned
* for `*.endpoint.ts` files (non-`_`-prefixed subfolders contribute
* to the inferred URL path).
*
* @example
* rootDirectory: './endpoints' // single scan root
* rootDirectory: ['endpoints', 'clinica'] // each entry scanned independently
*
* @default The directory of the calling file.
*/
rootDirectory?: string;
rootDirectory?: string | string[];
/**
* The base path to use for endpoints in this module.
* @default '/'
* The base path to prepend to every endpoint owned by this router.
*
* When omitted, `basePath` is inferred:
*
* - **Top-level `router.module.ts`**: the folder containing the file
* (e.g. `src/auth/router.module.ts` → `basePath: 'auth'`).
* - **Nested**: the parent's effective `basePath` joined with the
* child's folder relative to its matching parent root — or, if the
* child doesn't sit inside one of the parent's `rootDirectories`,
* relative to the parent module file's own folder.
* - **Other callsites** (e.g. `app.module.ts`, or any file not named
* `router.module.*`): no folder inference; defaults to `'/'`.
*
* Pass `''` or `'/'` to opt out of inference and mount at the root.
*/
basePath?: string;
imports?: ModuleMetadata['imports'];
Expand All @@ -70,46 +95,92 @@ export class EndpointsRouterModule {
*/
interceptors?: RouterInterceptor[];
}): Promise<DynamicModule> {
const definedAtDir = path.dirname(getCallsiteFile());
const callsiteFile = getCallsiteFile();
const definedAtDir = path.dirname(callsiteFile);
const resolveDir = (dir: string) => {
if (!path.isAbsolute(dir)) {
return path.join(definedAtDir, dir);
}
return dir;
};
const rootDirectory = params?.rootDirectory
? resolveDir(params.rootDirectory)
: definedAtDir;
const rootDirectories = ((): string[] => {
if (!params?.rootDirectory) {
return [definedAtDir];
}
const raw = Array.isArray(params.rootDirectory)
? params.rootDirectory
: [params.rootDirectory];
return raw.map(resolveDir);
})();
const parentStore = moduleAls.getStore();
const effectiveBasePath = ((): string => {
if (params?.basePath !== undefined) {
return params.basePath;
}
if (
parentStore?.parentRootDirectory &&
parentStore.parentRootDirectory !== rootDirectory
) {
const relative = path
.relative(parentStore.parentRootDirectory, definedAtDir)
.split(path.sep)
.join('/');
return '/' + relative;
if (parentStore) {
// If this child sits under (or is) one of the parent's
// rootDirectories, derive the suffix from that — this preserves
// the legacy `rootDirectory: './endpoints'` + nested-router
// pattern. Otherwise, use path.relative between module files.
const matchingRoot = parentStore.parentRootDirectories.find(
(r) =>
definedAtDir === r || definedAtDir.startsWith(r + path.sep),
);
let suffix: string;
if (matchingRoot) {
suffix =
definedAtDir === matchingRoot
? path.basename(definedAtDir)
: path.relative(matchingRoot, definedAtDir);
} else {
suffix = path.relative(
parentStore.parentModuleDir,
definedAtDir,
);
}
suffix = suffix.split(path.sep).join('/');
const segments = [
...parentStore.parentBasePath.split('/').filter(Boolean),
...suffix.split('/').filter(Boolean),
];
return segments.length > 0 ? '/' + segments.join('/') : '/';
}
// Top-level: infer from the router module file's own folder name,
// but only when the callsite is a `router.module.*` file. That way,
// calling `register()` from an `app.module.ts` (or any other module)
// keeps the legacy `'/'` default and doesn't silently prefix routes
// with the app's top folder.
if (routerModuleFileRegex.test(path.basename(callsiteFile))) {
return '/' + path.basename(definedAtDir);
}
return '/';
})();
let endpoints: Type[] = [];
const nestedRouterModuleFiles: string[] = [];
const endopointFiles = findEndpoints(
rootDirectory,
nestedRouterModuleFiles,
);
const endopointFiles: string[] = [];
for (const root of rootDirectories) {
findEndpoints(
root,
nestedRouterModuleFiles,
endopointFiles,
callsiteFile,
);
}
const endpointFilesNotImported = endopointFiles.filter((f) =>
settings.endpoints.every((e) => e.file !== f),
);
const nestedModules: (DynamicModule | Promise<DynamicModule>)[] = [];

const normalizedParentBasePath = effectiveBasePath.replace(
/^\/+|\/+$/g,
'',
);
await moduleAls.run(
{ parentRootDirectory: rootDirectory },
{
parentBasePath: normalizedParentBasePath,
parentModuleDir: definedAtDir,
parentRootDirectories: rootDirectories,
},
// eslint-disable-next-line @typescript-eslint/require-await -- needed since we are replacing the require with await import during build for esm
async () => {
for (const f of endpointFilesNotImported) {
Expand All @@ -131,15 +202,15 @@ export class EndpointsRouterModule {
for (const { setupFn } of settings.endpoints.filter((e) =>
endpointFilesNotImported.some((f) => f === e.file),
)) {
setupFn({ rootDirectory, basePath: effectiveBasePath });
setupFn({ rootDirectories, basePath: effectiveBasePath });
}
if (params?.endpoints) {
for (const ep of params.endpoints) {
const epSetupFn = Reflect.getMetadata('endpoints:setupFn', ep) as
| ((s: { rootDirectory: string; basePath: string }) => void)
| ((s: { rootDirectories: string[]; basePath: string }) => void)
| undefined;
if (epSetupFn) {
epSetupFn({ rootDirectory, basePath: effectiveBasePath });
epSetupFn({ rootDirectories, basePath: effectiveBasePath });
}
if (!endpoints.includes(ep)) {
endpoints.push(ep);
Expand Down Expand Up @@ -198,12 +269,11 @@ export class EndpointsRouterModule {
}
}
}
const normalizedBasePath = effectiveBasePath.replace(/^\/+|\/+$/g, '');
const excludeRoutes = middlewareOptions?.exclude?.map((p) =>
normalizedBasePath ? `${normalizedBasePath}/${p}` : p,
normalizedParentBasePath ? `${normalizedParentBasePath}/${p}` : p,
);
const forRoutesPattern = normalizedBasePath
? `${normalizedBasePath}/*`
const forRoutesPattern = normalizedParentBasePath
? `${normalizedParentBasePath}/*`
: '*';

class RouterModule
Expand Down Expand Up @@ -239,21 +309,36 @@ export class EndpointsRouterModule {
function findEndpoints(
dir: string,
nestedRouterModuleFiles: string[],
endopointFiles: string[] = [],
isRoot = true,
endopointFiles: string[],
callerRouterModuleFile: string,
) {
const files = fs.readdirSync(dir);
if (!isRoot) {
const routerFile = files.find((f) => f.match(routerModuleFileRegex));
if (routerFile) {
nestedRouterModuleFiles.push(path.join(dir, routerFile));
let files: string[];
try {
files = fs.readdirSync(dir);
} catch {
return endopointFiles;
}
// A directory that contains a `router.module.*` file is treated as a
// nested router — regardless of depth. The only exception is the
// caller's own router module file (avoids self-registration when a
// `rootDirectory` entry happens to resolve to the caller's own folder).
const routerFile = files.find((f) => f.match(routerModuleFileRegex));
if (routerFile) {
const routerFilePath = path.join(dir, routerFile);
if (routerFilePath !== callerRouterModuleFile) {
nestedRouterModuleFiles.push(routerFilePath);
return endopointFiles;
}
}
for (const f of files) {
const file = path.join(dir, f);
if (fs.statSync(file).isDirectory()) {
findEndpoints(file, nestedRouterModuleFiles, endopointFiles, false);
findEndpoints(
file,
nestedRouterModuleFiles,
endopointFiles,
callerRouterModuleFile,
);
}
if (f.match(endpointFileRegex)) {
endopointFiles.push(file);
Expand Down
31 changes: 27 additions & 4 deletions packages/nestjs-endpoints/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ const shortCircuitDirs: Record<string, boolean> = {
[process.cwd()]: true,
};
export function getEndpointHttpPath(
rootDirectory: string,
rootDirectories: string[],
basePath: string,
file: string,
) {
shortCircuitDirs[rootDirectory] = true;
const stopDirs: Record<string, boolean> = { ...shortCircuitDirs };
for (const d of rootDirectories) {
stopDirs[d] = true;
}
let pathSegments: string[] = [];
let start = path.dirname(file);
let lastDirPathSegment: string | null = null;

while (true) {
if (
Object.keys(shortCircuitDirs).some(
Object.keys(stopDirs).some(
(d) =>
path.normalize(d + path.sep) ===
path.normalize(start + path.sep),
Expand Down Expand Up @@ -130,5 +133,25 @@ export function getCallsiteFile() {
}

export const moduleAls = new AsyncLocalStorage<{
parentRootDirectory: string;
/**
* The parent router's effective basePath (without leading slash, joined
* with '/'). Used by nested child routers to prefix their own inferred
* basePath with the parent's.
*/
parentBasePath: string;
/**
* The directory containing the parent router module file (i.e., the
* directory in which the parent's `router.module.ts` lives). Child
* routers infer their basePath suffix from their own module directory
* relative to this when they don't sit inside one of the parent's
* `rootDirectories`.
*/
parentModuleDir: string;
/**
* The parent router's resolved root directories. When a child router's
* own directory matches (or sits inside) one of these, the basePath
* suffix is computed relative to that root — preserving the legacy
* `rootDirectory: './endpoints'` + nested-router pattern.
*/
parentRootDirectories: string[];
}>();
4 changes: 4 additions & 0 deletions packages/test/test-app-express-cjs/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Observable, catchError, throwError } from 'rxjs';
import { ZodError } from 'zod';
import { AuthModule } from './auth/auth.module';
import BlogRouterModule from './blog/router.module';
import { HelloService } from './endpoints/hello.service';
import { RecipesManualEndpointsRouterModule } from './endpoints/recipes-manual/router.module';
import {
Expand All @@ -28,6 +29,7 @@ import {
IUserRepository,
UserService,
} from './endpoints/user/user.service';
import ShopRouterModule from './shop/router.module';
import { VanillaController } from './vanilla.controller';

@Injectable()
Expand Down Expand Up @@ -67,6 +69,8 @@ export class ZodErrorInterceptor implements NestInterceptor {
],
}),
RecipesManualEndpointsRouterModule,
ShopRouterModule,
BlogRouterModule,
UserListModule,
],
providers: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { endpoint, z } from 'nestjs-endpoints';

export default endpoint({
output: z.object({ title: z.string() }),
handler: () => ({ title: 'Hello' }),
});
Loading
Loading