@basaltkit/express 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -5
- package/dist/index.d.ts +8 -2
- package/dist/index.js +12 -5
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Basalt adapter for [Express](https://expressjs.com): the same typed routes, enri
|
|
|
14
14
|
|
|
15
15
|
This module connects Express to Basalt. **Routes** (address + method, e.g. `POST /echo`) are defined with the `route()` function from `@basaltkit/http`, in a neutral format with [Zod](https://zod.dev) schemas for validation. The adapter converts each Express request into that neutral format, runs the shared pipeline (validation, *enrichers* — functions that enrich the request context, like resolving the tenant — and *guards* — functions that can reject the request, like authentication), and converts errors into JSON responses with a stable format.
|
|
16
16
|
|
|
17
|
-
The strong point: **portability**. A route written for this adapter runs unchanged on `@basaltkit/fastify` and `@basaltkit/hono
|
|
17
|
+
The strong point: **portability**. A route written for this adapter runs unchanged on `@basaltkit/fastify` and `@basaltkit/hono` — **the three adapters are equals**. Routes, enrichers, guards, the per-request context, standardized errors, the neutral 404, ETags (`meta.etag`), SSE, per-route rate limits and the boot-time guarded-meta check all behave identically. The neutral edge plugins (security, health, metrics, tracing, OpenAPI) from `@basaltkit/http` work here exactly the same.
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
@@ -70,7 +70,7 @@ curl -X POST http://localhost:3000/echo \
|
|
|
70
70
|
# → 400 {"error":{"code":"HTTP_VALIDATION","part":"body","issues":[...]}}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
> The plugin
|
|
73
|
+
> The plugin enables `express.json()` **and** `express.urlencoded({ extended: false })` for you — you don't need to configure body parsing. (The urlencoded parser is what makes HTML forms and the SAML ACS binding work.)
|
|
74
74
|
|
|
75
75
|
## Usage guide
|
|
76
76
|
|
|
@@ -101,6 +101,39 @@ const boom = route({
|
|
|
101
101
|
|
|
102
102
|
The error format is identical to the other adapters: `{ error: { code, message, ... } }`. Unexpected errors respond with `500` and `INTERNAL_ERROR`, without exposing internal details.
|
|
103
103
|
|
|
104
|
+
### Guarded route meta — the boot check
|
|
105
|
+
|
|
106
|
+
If a route declares `meta.auth`, `meta.can` or `meta.teamRole` and **no registered plugin
|
|
107
|
+
enforces that key**, the route would serve unprotected. `expressPlugin` refuses to boot:
|
|
108
|
+
it calls `assertRoutesGuarded()` in its boot phase and throws `UnguardedRouteMetaError`
|
|
109
|
+
(code `HTTP_UNGUARDED_ROUTE_META`), naming every offending route and key, before a single
|
|
110
|
+
request is served.
|
|
111
|
+
|
|
112
|
+
Fix it by registering the enforcing plugin (`auth` → `authPlugin`, `can` →
|
|
113
|
+
`permissionsPlugin`, `teamRole` → `teamsPlugin`). If protection really does happen at an
|
|
114
|
+
outer edge (a gateway that authenticates first), waive it explicitly:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
expressPlugin({ routes, allowUnguardedMeta: true }) // waive every key
|
|
118
|
+
expressPlugin({ routes, allowUnguardedMeta: ['auth'] }) // waive one key
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Fastify and Hono run the identical check with the identical option.
|
|
122
|
+
|
|
123
|
+
### The neutral 404
|
|
124
|
+
|
|
125
|
+
Unmatched routes get the same JSON body on every adapter —
|
|
126
|
+
`{ "error": { "code": "NOT_FOUND", "message": "Route not found." } }` (`NOT_FOUND_RESPONSE`
|
|
127
|
+
from `@basaltkit/http`) — instead of Express's HTML default, which fingerprints the
|
|
128
|
+
framework. It is mounted last, at `app:booted`. Pass `notFound: false` to keep Express's
|
|
129
|
+
own handling, e.g. when your app mounts its own catch-all afterwards.
|
|
130
|
+
|
|
131
|
+
### Streaming — SSE
|
|
132
|
+
|
|
133
|
+
A handler returning `sse(producer)` from `@basaltkit/http` is streamed straight onto the
|
|
134
|
+
Express response (`res.writeHead(200, SSE_HEADERS)`), with client disconnects relayed to
|
|
135
|
+
`stream.onClose()`. Same handler code as on Fastify and Hono.
|
|
136
|
+
|
|
104
137
|
### Enrichers and guards (authentication, tenancy, …)
|
|
105
138
|
|
|
106
139
|
Plugins register these functions in the container's metadata "buckets"; the adapter applies them to every route. Real example (from the package's tests) — a tenancy-style enricher and an auth-style guard:
|
|
@@ -213,11 +246,26 @@ In this mode each handler already handles its own errors (the wrapper responds w
|
|
|
213
246
|
|---|---|---|---|---|
|
|
214
247
|
| `routes` | `BasaltRoute[]` | No | `[]` | Routes (created with `route()` from `@basaltkit/http`) to mount. |
|
|
215
248
|
| `allowUnguardedMeta` | `boolean \| string[]` | No | fail loud at boot | Waives the boot check that every route declaring security meta (`auth`, `can`, `teamRole`) has a registered guard enforcing it (`UnguardedRouteMetaError` otherwise). `true` waives everything (edge/gateway auth); an array waives specific keys. |
|
|
216
|
-
| `app` | `Express` | No | new `express()` | Bring your own Express app; either way, `express.json()`
|
|
249
|
+
| `app` | `Express` | No | new `express()` | Bring your own Express app; either way, `express.json()` and `express.urlencoded({ extended: false })` are added. |
|
|
250
|
+
| `notFound` | `boolean` | No | `true` | Serve `NOT_FOUND_RESPONSE` (the neutral JSON 404) for unmatched routes, mounted last. Set `false` to keep Express's HTML default or your own catch-all. |
|
|
217
251
|
|
|
218
252
|
Behavior: registers the Express app under the `EXPRESS` token and an `HttpServerCollector` under the `HTTP_SERVER` token. On the `app:booted` event it mounts everything in the order Express requires: *after-hooks* middleware (metrics/tracing, via `res.on('finish')`) → *pre-hooks* middleware (security/CORS/rate limit; if one of them responds, the route doesn't run) → Basalt routes → extra routes from edge plugins (`/livez`, `/metrics`, …). Publishes the routes in the `'http:routes'` metadata bucket for OpenAPI/CLI/SDK.
|
|
219
253
|
|
|
220
|
-
> Note: unlike `fastifyPlugin`, this plugin has no `shutdown` step —
|
|
254
|
+
> Note: unlike `fastifyPlugin`, this plugin has no `shutdown` step — Basalt never calls `listen()` for you, so closing the server it returns is your responsibility.
|
|
255
|
+
|
|
256
|
+
### Errors
|
|
257
|
+
|
|
258
|
+
| Error | Code | HTTP | When |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| `RequestValidationError` | `HTTP_VALIDATION` | 400 | `body`/`query`/`params` failed its Zod schema. Response carries `part` + `issues[]`. |
|
|
261
|
+
| `HttpError(status, code, message)` | *yours* | *yours* | Thrown deliberately from any layer. |
|
|
262
|
+
| `UnguardedRouteMetaError` | `HTTP_UNGUARDED_ROUTE_META` | — (boot) | A route declares a guarded key (`auth`/`can`/`teamRole`/`scopes`/`subscribed`/`feature`) with no guard enforcing it. Waive with `allowUnguardedMeta`. |
|
|
263
|
+
| — | `NOT_FOUND` | 404 | No route matched (unless `notFound: false`). |
|
|
264
|
+
| — | `RATE_LIMITED` | 429 | `securityPlugin`'s limiter rejected the request. |
|
|
265
|
+
| — | `INTERNAL_ERROR` | 500 | Any other thrown error. The real message never reaches the client. |
|
|
266
|
+
|
|
267
|
+
All of these are produced by the shared `@basaltkit/http` pipeline, so the bodies are
|
|
268
|
+
byte-identical to Fastify's and Hono's.
|
|
221
269
|
|
|
222
270
|
### `EXPRESS`
|
|
223
271
|
|
|
@@ -235,7 +283,7 @@ Dependency-injection token (`Token<Express>`): `app.container.get(EXPRESS)` retu
|
|
|
235
283
|
|
|
236
284
|
### What to import from where
|
|
237
285
|
|
|
238
|
-
This package only exports `expressPlugin`, `registerRoutes`, `EXPRESS`, and `ExpressPluginOptions`. Everything else — `route`, `HttpError`, `RequestValidationError`, `securityPlugin`, `healthPlugin`, `metricsPlugin`, `tracingPlugin`, `openapiPlugin`, types like `RequestEnricher`/`RouteGuard` — is imported from **`@basaltkit/http`**.
|
|
286
|
+
This package only exports `expressPlugin`, `registerRoutes`, `EXPRESS`, and `ExpressPluginOptions`. Everything else — `route`, `HttpError`, `RequestValidationError`, `NOT_FOUND_RESPONSE`, `sse`, `securityPlugin`, `RedisRateLimitStore`, `healthPlugin`, `metricsPlugin`, `tracingPlugin`, `openapiPlugin`, `escapeHtml`/`pageCsp`, types like `RequestEnricher`/`RouteGuard` — is imported from **`@basaltkit/http`**. (Unlike `@basaltkit/fastify`, this package re-exports nothing; that is a naming choice, not a capability gap.)
|
|
239
287
|
|
|
240
288
|
## Common errors and solutions (FAQ)
|
|
241
289
|
|
|
@@ -258,3 +306,6 @@ This package only exports `expressPlugin`, `registerRoutes`, `EXPRESS`, and `Exp
|
|
|
258
306
|
- **`@basaltkit/fastify` / `@basaltkit/hono`** — sibling adapters: the same routes, enrichers, guards, and edge plugins run on any of them unchanged; switching frameworks is just switching plugins.
|
|
259
307
|
- **`@basaltkit/auth` / `@basaltkit/tenancy` / `@basaltkit/permissions`** — register guards/enrichers in `'http:guards'`/`'http:enrichers'` and read the routes' `meta` (e.g. `meta: { auth: true }`); this adapter applies them automatically.
|
|
260
308
|
- **`@basaltkit/sdk` and the CLI** — consume the `'http:routes'` bucket (routes + Zod schemas) that this plugin publishes.
|
|
309
|
+
- **`@basaltkit/testing`** — `createTestApp({ adapter: 'express' })` runs your suite against this adapter (it listens on an ephemeral 127.0.0.1 port and fetches, because Express has no in-process inject).
|
|
310
|
+
|
|
311
|
+
Guides: [Adapters](/guide/adapters) · [Migrating from Express](/guide/migrating-from-express) · [Testing](/guide/testing) · [Security](/guide/security)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Container } from '@basaltkit/core';
|
|
2
|
-
import { type BasaltRoute, type RequestEnricher, type RouteGuard } from '@basaltkit/http';
|
|
2
|
+
import { type HttpErrorReporter, type BasaltRoute, type RequestEnricher, type RouteGuard } from '@basaltkit/http';
|
|
3
3
|
import { type Express } from 'express';
|
|
4
4
|
export declare const EXPRESS: import("@basaltkit/core").Token<Express>;
|
|
5
5
|
/** Mounts Basalt routes on an Express app (usable without the plugin). */
|
|
6
|
-
export declare function registerRoutes(app: Express, routes: BasaltRoute[], container?: Container, enrichers?: RequestEnricher[], guards?: RouteGuard[]): void;
|
|
6
|
+
export declare function registerRoutes(app: Express, routes: BasaltRoute[], container?: Container, enrichers?: RequestEnricher[], guards?: RouteGuard[], onError?: HttpErrorReporter): void;
|
|
7
7
|
export interface ExpressPluginOptions {
|
|
8
8
|
routes?: BasaltRoute[];
|
|
9
9
|
/**
|
|
@@ -21,6 +21,12 @@ export interface ExpressPluginOptions {
|
|
|
21
21
|
* HTML default. Default: true. Pass false to keep Express's own handling
|
|
22
22
|
* (e.g. when the app mounts its own catch-all after boot).
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* Where failed requests are reported. Default: 5xx via `console.error` (with
|
|
26
|
+
* the stack) and 4xx via `console.warn`, prefixed `[basalt:http]`. Pass your
|
|
27
|
+
* own to route them into a real logger, or `() => {}` to silence them.
|
|
28
|
+
*/
|
|
29
|
+
onError?: HttpErrorReporter;
|
|
24
30
|
notFound?: boolean;
|
|
25
31
|
}
|
|
26
32
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Container, createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
|
-
import { NOT_FOUND_RESPONSE, HttpServerCollector, HTTP_SERVER, runRoute, toErrorResponse, isSseResponse, sseProducerOf, driveSse, SSE_HEADERS, GUARDED_META_BUCKET, assertRoutesGuarded, } from '@basaltkit/http';
|
|
2
|
+
import { NOT_FOUND_RESPONSE, HttpServerCollector, HTTP_SERVER, runRoute, toErrorResponse, reportHttpError, isSseResponse, sseProducerOf, driveSse, SSE_HEADERS, GUARDED_META_BUCKET, assertRoutesGuarded, } from '@basaltkit/http';
|
|
3
3
|
import express, {} from 'express';
|
|
4
4
|
export const EXPRESS = createToken('express');
|
|
5
5
|
function toNeutralRequest(req) {
|
|
@@ -52,7 +52,7 @@ class ExpressReply {
|
|
|
52
52
|
return this;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
function basaltHandler(definition, container, enrichers, guards) {
|
|
55
|
+
function basaltHandler(definition, container, enrichers, guards, onError) {
|
|
56
56
|
return async (req, res) => {
|
|
57
57
|
const reply = new ExpressReply(res);
|
|
58
58
|
try {
|
|
@@ -75,16 +75,23 @@ function basaltHandler(definition, container, enrichers, guards) {
|
|
|
75
75
|
}
|
|
76
76
|
catch (error) {
|
|
77
77
|
const { status, body } = toErrorResponse(error);
|
|
78
|
+
// This adapter previously reported nothing at all — a 500 reached the
|
|
79
|
+
// client and left no trace whatsoever on the server.
|
|
80
|
+
const entry = { error, status, code: body.error.code, method: req.method, url: req.originalUrl };
|
|
81
|
+
if (onError)
|
|
82
|
+
onError(entry);
|
|
83
|
+
else
|
|
84
|
+
reportHttpError(entry);
|
|
78
85
|
if (!res.headersSent)
|
|
79
86
|
res.status(status).json(body);
|
|
80
87
|
}
|
|
81
88
|
};
|
|
82
89
|
}
|
|
83
90
|
/** Mounts Basalt routes on an Express app (usable without the plugin). */
|
|
84
|
-
export function registerRoutes(app, routes, container, enrichers = [], guards = []) {
|
|
91
|
+
export function registerRoutes(app, routes, container, enrichers = [], guards = [], onError) {
|
|
85
92
|
const router = app;
|
|
86
93
|
for (const definition of routes) {
|
|
87
|
-
router[definition.method.toLowerCase()](definition.url, basaltHandler(definition, container, enrichers, guards));
|
|
94
|
+
router[definition.method.toLowerCase()](definition.url, basaltHandler(definition, container, enrichers, guards, onError));
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
/**
|
|
@@ -134,7 +141,7 @@ export function expressPlugin(options = {}) {
|
|
|
134
141
|
return;
|
|
135
142
|
next();
|
|
136
143
|
});
|
|
137
|
-
registerRoutes(app, routes, container, enrichers, guards);
|
|
144
|
+
registerRoutes(app, routes, container, enrichers, guards, options.onError);
|
|
138
145
|
for (const { method, url, handler } of collector.extraRoutes) {
|
|
139
146
|
router[method.toLowerCase()](url, async (req, res) => {
|
|
140
147
|
const reply = new ExpressReply(res);
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/express",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.5.0"
|
|
6
|
+
},
|
|
4
7
|
"description": "Express adapter for Basalt: run the same typed routes, enrichers and guards on Express that you would on Fastify or Hono.",
|
|
5
8
|
"license": "MIT",
|
|
6
9
|
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +18,8 @@
|
|
|
14
18
|
"dist"
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
|
-
"@basaltkit/core": "^1.3.
|
|
18
|
-
"@basaltkit/http": "^1.
|
|
21
|
+
"@basaltkit/core": "^1.3.1",
|
|
22
|
+
"@basaltkit/http": "^1.15.0"
|
|
19
23
|
},
|
|
20
24
|
"peerDependencies": {
|
|
21
25
|
"express": "^4.19.0 || ^5.0.0"
|