The core, explained.
The runtime underneath your application. Small, typed, and explicit.
@exetjs/core creates the Express application, mounts your modules, and starts the HTTP server. It also exports response helpers, application errors, and Zod request validation.
Reference for the published core package 0.1.0, including its exported types and implementation.
Installation #
CLI-generated applications already include the core. For manual integration, use a Node.js 24+ ESM project with "type": "module" in package.json.
pnpm add @exetjs/core express zodZod is a peer dependency of the core. Declare Express directly when your own application imports it. For a TypeScript development workflow, add the local compiler, runner and Node/Express types:
pnpm add -D typescript tsx @types/node @types/expressPrefer the CLI quick start when you want the generated configuration, middleware, health module, and build scripts included from the beginning.
Application bootstrap #
The lifecycle has three explicit steps: create the context, await module registration, then start the server. The example below uses the module and error handler defined later in this guide.
import {
createExetApp,
registerExetModules,
startExetServer,
notFound,
type ExetAppConfig,
type ExetLogger,
} from '@exetjs/core';
import { helloModule } from './modules/hello.module.js';
import { errorHandler } from './errorHandler.js';
const config: ExetAppConfig = {
appName: 'my-api',
nodeEnv: 'development',
host: '127.0.0.1',
port: 5000,
apiPrefix: '/api/v1',
cors: { enabled: false, origin: 'http://localhost:5173' },
logger: { level: 'info' },
};
const logger: ExetLogger = console;
const ctx = createExetApp({ config, logger });
// Add application-wide middleware before registration.
await registerExetModules(ctx, [helloModule]);
// Handle unmatched routes after the modules.
ctx.app.use((_req, _res, next) => next(notFound()));
ctx.app.use(errorHandler);
const server = await startExetServer(ctx);
server.on('error', (error) => {
logger.error('HTTP server error', error);
process.exitCode = 1;
});Use pnpm exec tsx src/main.ts in your manually configured project. The example route is GET /api/v1/hello. Configuration values here are explicit example values; see the CLI configuration guide for environment overrides.
createExetApp #
createExetApp(options: {
config: ExetAppConfig;
logger: ExetLogger;
}): ExetContextReturns { app, config, logger }. The Express application is created, but is not yet listening for requests.
- Disables the
X-Powered-Byheader and installs Helmet. - Adds CORS when
config.cors.enabledis true, with the configured origin andcredentials: true. - Installs JSON and URL-encoded body parsers with a
1mblimit. URL-encoded parsing usesextended: true.
Core accepts the logger and resolved configuration you supply. It does not read .env, discover modules, or install the generated project's logging, 404, or error middleware for you. A supplied console logger does not apply logger.level filtering.
registerExetModules #
registerExetModules(ctx: ExetContext, modules: ExetModule[]): Promise<void>Processes modules sequentially in array order. It awaits each register(ctx), mounts the returned router, and logs the registered module name and path.
/api/v1 + /hello + /
API prefix basePath router path
GET /api/v1/helloVersion 0.1.0 joins apiPrefix and basePath by string concatenation. Use a prefix without a trailing slash and a module path with a leading slash. There is no automatic module discovery or duplicate-name guard.
startExetServer #
startExetServer(ctx: ExetContext): Promise<Server>Calls Express app.listen(config.port, config.host) and returns the Node HTTP server. The listening callback logs the application name, environment, host, port and API prefix.
The returned promise wraps the server object; it does not wait for the listening event. Attach server error handling and use Node's server events when your application needs an explicit readiness signal. Your application owns shutdown and resource cleanup.
Module contract #
A module defines a name, a base path, and a registration function. Registration receives the shared context and returns an Express router. It may also return a promise.
type ExetModuleRegisterResult = { router: Router };
type ExetModule = {
name: string;
basePath: string;
register: (ctx: ExetContext) =>
ExetModuleRegisterResult | Promise<ExetModuleRegisterResult>;
};import { Router } from 'express';
import { apiResponse, type ExetModule } from '@exetjs/core';
export const helloModule: ExetModule = {
name: 'hello',
basePath: '/hello',
register: (ctx) => {
const router = Router();
router.get('/', (_req, res) => {
res.json(apiResponse({ message: 'Hello, ExetJS.' }));
});
ctx.logger.info('Hello module initialized');
return { router };
},
};In a generated application, export the module through src/modules/index.ts. Preserve the CLI registry markers. See custom modules for the registration example.
Configuration & context #
type ExetAppConfig = {
appName: string;
nodeEnv: 'development' | 'test' | 'production';
host: string;
port: number;
apiPrefix: string;
cors: { enabled: boolean; origin: string };
logger: { level: string };
};
type ExetContext = {
app: Express;
config: ExetAppConfig;
logger: ExetLogger;
};ExetAppConfig is the resolved runtime configuration. ExetProjectConfig describes the project on disk, including entry points, registry paths, environment file paths, server defaults, and package manager. The CLI reads the latter from exet.config.ts.
type ExetLogger = {
info: (message: string, meta?: unknown) => void;
warn: (message: string, meta?: unknown) => void;
error: (message: string, meta?: unknown) => void;
debug?: (message: string, meta?: unknown) => void;
};Adapt your preferred logger to this contract. ExetJS passes it through to module registration and runtime startup.
API responses #
Response helpers build plain JSON-compatible objects. They do not send the response or choose its HTTP status.
import { apiResponse, apiErrorResponse } from '@exetjs/core';
res.status(200).json(apiResponse({ id: 'example-id' }));
res.status(200).json(apiResponse(items, { page: 1 }));
res.status(404).json(apiErrorResponse('Not found', 'NOT_FOUND'));// apiResponse<T>(data: T, meta?: unknown)
{ success: true, data: T, meta?: unknown }
// apiErrorResponse(message, code = 'APP_ERROR', details?)
{
success: false,
error: { message: string, code: string, details?: unknown }
}meta and details are omitted when undefined. The response object types are inferred from the functions; version 0.1.0 does not export their type aliases from the package root.
Error handling #
new AppError(
message: string,
statusCode = 500,
code = 'APP_ERROR',
details?: unknown,
)The error exposes statusCode, code, and optional details. HTTP helpers construct an AppError with a specific status and code:
| Helper | Status | Code |
|---|---|---|
badRequest(message?, details?) | 400 | BAD_REQUEST |
unauthorized(message?, details?) | 401 | UNAUTHORIZED |
forbidden(message?, details?) | 403 | FORBIDDEN |
notFound(message?, details?) | 404 | NOT_FOUND |
Pass errors to Express error middleware. CLI-generated projects include a handler; manual integrations need their own. This minimal example handles validation and operational errors without returning internal exception details.
import type { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import { AppError, apiErrorResponse } from '@exetjs/core';
export const errorHandler: ErrorRequestHandler =
(error: unknown, _req, res, next) => {
if (res.headersSent) {
next(error);
return;
}
if (error instanceof ZodError) {
res.status(400).json(
apiErrorResponse('Invalid request', 'VALIDATION_ERROR'),
);
return;
}
if (error instanceof AppError) {
res.status(error.statusCode).json(
apiErrorResponse(error.message, error.code),
);
return;
}
res.status(500).json(
apiErrorResponse('Internal server error', 'INTERNAL_ERROR'),
);
};Request validation #
validateRequest(schema) returns Express middleware. It synchronously parses an object containing body, params, and query through Zod, stores the result in res.locals.validated, then calls next().
import { Router } from 'express';
import { z } from 'zod';
import { apiResponse, validateRequest } from '@exetjs/core';
const schema = z.object({
body: z.object({ name: z.string().trim().min(2) }),
});
type Input = z.infer<typeof schema>;
const router = Router();
router.post('/', validateRequest(schema), (_req, res) => {
const input = res.locals.validated as Input;
res.status(201).json(apiResponse({ name: input.body.name }));
});The middleware does not replace req.body, req.params, or req.query. Read res.locals.validated to use transformations, coercions, and defaults from the schema. Async refinements are not supported by this synchronous helper.
A validation failure throws a Zod error into Express error handling. Place the error handler after your routes.
Exported types #
These six types are available directly from @exetjs/core:
| Type | Purpose |
|---|---|
ExetAppConfig | Resolved application name, environment, host, port, prefix, CORS and logger settings. |
ExetContext | The Express app, resolved configuration and logger passed to every module. |
ExetLogger | info, warn and error methods; an optional debug method. Each accepts a message and optional metadata. |
ExetModule | A module name, basePath and synchronous or asynchronous register function. |
ExetModuleRegisterResult | The result of module registration: { router: Router }. |
ExetProjectConfig | The CLI project configuration: runtime paths, server defaults, module paths and package manager. |
import type {
ExetAppConfig,
ExetContext,
ExetLogger,
ExetModule,
ExetModuleRegisterResult,
ExetProjectConfig,
} from '@exetjs/core';