@zeroxsolutions/server 0.3.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/CHANGELOG.md +91 -0
- package/README.md +128 -0
- package/dist/content-negotiation.d.ts +17 -0
- package/dist/content-negotiation.d.ts.map +1 -0
- package/dist/content-negotiation.js +25 -0
- package/dist/environment.d.ts +19 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +29 -0
- package/dist/error-handler.d.ts +26 -0
- package/dist/error-handler.d.ts.map +1 -0
- package/dist/error-handler.js +60 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/request-invalid.d.ts +12 -0
- package/dist/request-invalid.d.ts.map +1 -0
- package/dist/request-invalid.js +15 -0
- package/dist/surface-vocabulary.d.ts +11 -0
- package/dist/surface-vocabulary.d.ts.map +1 -0
- package/dist/surface-vocabulary.js +1 -0
- package/dist/validation-hook.d.ts +14 -0
- package/dist/validation-hook.d.ts.map +1 -0
- package/dist/validation-hook.js +47 -0
- package/package.json +45 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
## 0.3.0 (2026-09-13)
|
|
2
|
+
|
|
3
|
+
### 💅 Refactors
|
|
4
|
+
|
|
5
|
+
- ⚠️ **server:** render a rejected request through the error handler ([59b6f32](https://github.com/zeroxsolutions/zeroxsolutions/commit/59b6f32))
|
|
6
|
+
|
|
7
|
+
### ⚠️ Breaking Changes
|
|
8
|
+
|
|
9
|
+
- **server:** render a rejected request through the error handler ([59b6f32](https://github.com/zeroxsolutions/zeroxsolutions/commit/59b6f32))
|
|
10
|
+
`createValidationHook` throws the rejection instead of
|
|
11
|
+
answering it, so an app passing it to `OpenAPIHono` also needs
|
|
12
|
+
`createErrorHandler` as its `onError`; without one hono answers the
|
|
13
|
+
rejection with a text 500. The hook's type is now
|
|
14
|
+
`Hook<unknown, E, string, undefined>`. The handler asks `resolve` before
|
|
15
|
+
it renders the rejection, so a `resolve` answering every error now
|
|
16
|
+
answers validation failures too. Where `requestId` is configured, a
|
|
17
|
+
validation 400 or 422 carries it as each error object's `id`, and every
|
|
18
|
+
error document the handler renders carries `jsonapi: { version: '1.1' }`.
|
|
19
|
+
|
|
20
|
+
### ❤️ Thank You
|
|
21
|
+
|
|
22
|
+
- Claude Sonnet 5
|
|
23
|
+
- Lương Văn Tú
|
|
24
|
+
|
|
25
|
+
## 0.2.4 (2026-09-13)
|
|
26
|
+
|
|
27
|
+
This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
|
|
28
|
+
|
|
29
|
+
## 0.2.3 (2026-09-13)
|
|
30
|
+
|
|
31
|
+
This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
|
|
32
|
+
|
|
33
|
+
## 0.2.2 (2026-09-13)
|
|
34
|
+
|
|
35
|
+
### 🧱 Updated Dependencies
|
|
36
|
+
|
|
37
|
+
- Updated @zeroxsolutions/response to 0.11.2
|
|
38
|
+
- Updated @zeroxsolutions/jsonapi to 0.10.2
|
|
39
|
+
|
|
40
|
+
## 0.2.1 (2026-09-07)
|
|
41
|
+
|
|
42
|
+
Repackaged only, no source change: the previous tarball still carried the pre-flatten `dist/hono/http/` and `dist/hono/jsonapi/` tree, which `tsc --build` leaves behind when a source file is renamed away. 48 entries down to 21.
|
|
43
|
+
|
|
44
|
+
## 0.2.0 (2026-09-07)
|
|
45
|
+
|
|
46
|
+
### 🚀 Features
|
|
47
|
+
|
|
48
|
+
- ⚠️ **server,jsonapi:** one surface, one error-code vocabulary ([32b4aed](https://github.com/zeroxsolutions/zeroxsolutions/commit/32b4aed))
|
|
49
|
+
|
|
50
|
+
### ⚠️ Breaking Changes
|
|
51
|
+
|
|
52
|
+
- **server,jsonapi:** one surface, one error-code vocabulary ([32b4aed](https://github.com/zeroxsolutions/zeroxsolutions/commit/32b4aed))
|
|
53
|
+
createErrorHandler, createValidationHook and
|
|
54
|
+
jsonApiContentNegotiation take a required `namespace`; negotiateJsonApi takes it
|
|
55
|
+
as a third argument. Codes these mint are now `<namespace>.<name>`.
|
|
56
|
+
|
|
57
|
+
### ❤️ Thank You
|
|
58
|
+
|
|
59
|
+
- Lương Văn Tú
|
|
60
|
+
|
|
61
|
+
## 0.1.0 (2026-09-07)
|
|
62
|
+
|
|
63
|
+
### 💅 Refactors
|
|
64
|
+
|
|
65
|
+
- ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
|
|
66
|
+
|
|
67
|
+
### ⚠️ Breaking Changes
|
|
68
|
+
|
|
69
|
+
- **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
|
|
70
|
+
`@zeroxsolutions/response/http`, `@zeroxsolutions/query/http`
|
|
71
|
+
and `@zeroxsolutions/server/hono/http` are gone, and the surviving `/jsonapi`
|
|
72
|
+
and `/hono` subpaths move to the package root. Every import drops its subpath.
|
|
73
|
+
|
|
74
|
+
### ❤️ Thank You
|
|
75
|
+
|
|
76
|
+
- Lương Văn Tú
|
|
77
|
+
|
|
78
|
+
## 0.0.3 (2026-09-07)
|
|
79
|
+
|
|
80
|
+
This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
|
|
81
|
+
|
|
82
|
+
## 0.0.2 (2026-09-07)
|
|
83
|
+
|
|
84
|
+
### 🚀 Features
|
|
85
|
+
|
|
86
|
+
- **server:** add the package holding every house Hono handler ([2b1f05d](https://github.com/zeroxsolutions/zeroxsolutions/commit/2b1f05d))
|
|
87
|
+
|
|
88
|
+
### ❤️ Thank You
|
|
89
|
+
|
|
90
|
+
- Claude Opus 5 (1M context)
|
|
91
|
+
- Lương Văn Tú
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @zeroxsolutions/server
|
|
2
|
+
|
|
3
|
+
Every Hono handler the house ships: the middlewares, the `onError` and the `defaultHook` a worker wires
|
|
4
|
+
before its routes. A concern package holds the DECISION as a plain function over values;
|
|
5
|
+
this one holds the handler that calls it. That split is what leaves the concern packages framework-free.
|
|
6
|
+
|
|
7
|
+
| Holds | Wires the decisions in |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `createEnvironment` | the worker's own `bindingsSchema` - no package owns a worker's bindings |
|
|
10
|
+
| `createErrorHandler`, `createValidationHook`, `jsonApiContentNegotiation` | `@zeroxsolutions/response`, `@zeroxsolutions/jsonapi` |
|
|
11
|
+
|
|
12
|
+
## What the split bought
|
|
13
|
+
|
|
14
|
+
| Package | Peers before | Peers now |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `@zeroxsolutions/jsonapi` | `hono` | none - plain functions over strings and documents, which is what an SPA loads |
|
|
17
|
+
| `@zeroxsolutions/response` | `@hono/zod-openapi`, `@logtape/logtape`, `hono`, `zod` | `zod` - `hono` left with the plain spelling |
|
|
18
|
+
|
|
19
|
+
That is the test for anything else moving here: it moves when the move DROPS a framework peer from the
|
|
20
|
+
package it left. `auth()` and `permission()` do not - the provider's own surface decides them, and
|
|
21
|
+
`hasPermission` is a call rather than a shape. Neither does `dependencies()`, which registers one
|
|
22
|
+
context's own tokens and so has nothing to share.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add @zeroxsolutions/server
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`hono`, `zod` and `@logtape/logtape` are peers - the worker already declares them.
|
|
31
|
+
`@zeroxsolutions/jsonapi` and `@zeroxsolutions/response` come with it, as dependencies: this package
|
|
32
|
+
CALLS them rather than reading a host's instance.
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
`createEnvironment` builds the gate that refuses a request whose bindings do not match the schema the
|
|
37
|
+
transport declares its `Bindings` from:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// packages/<context>/src/entrypoints/http/middlewares/environment.ts
|
|
41
|
+
import { createEnvironment } from '@zeroxsolutions/server';
|
|
42
|
+
import { LOG_CATEGORY } from '../../../lib/constants/index.js';
|
|
43
|
+
import { bindingsSchema, type AppEnvironment } from '../env.js';
|
|
44
|
+
|
|
45
|
+
/** Refuses a request whose environment does not match `bindingsSchema`, before any other middleware reads a field. */
|
|
46
|
+
export const environment = createEnvironment<AppEnvironment>({ schema: bindingsSchema, category: LOG_CATEGORY });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The mount site is unchanged, because the binder returns a factory rather than the middleware:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// entrypoints/http/app.ts - first, so a missing binding is named once rather than at first use
|
|
53
|
+
app.use('*', environment());
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A rejected parse logs `app.env.invalid` with the failing PATHS and the request id, then throws an error
|
|
57
|
+
carrying no field name - which input an operator failed to set is not the caller's business, and the
|
|
58
|
+
transport's `onError` renders it as a bare 500.
|
|
59
|
+
|
|
60
|
+
`jsonApiContentNegotiation` binds to nothing of the product's - the media type and the parameter rule
|
|
61
|
+
are the standard's, identical on every surface answering it. It takes the surface's `namespace` for the
|
|
62
|
+
same reason the other two do: a refusal it mints is an error object like any other, and a client reading
|
|
63
|
+
`not_acceptable` beside `api.place.not_found` cannot tell which surface spoke.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// entrypoints/http/app.ts - one middleware, before the routes, never a per-handler check
|
|
67
|
+
import { jsonApiContentNegotiation } from '@zeroxsolutions/server';
|
|
68
|
+
|
|
69
|
+
app.use('*', jsonApiContentNegotiation({ namespace: 'api' }));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
It refuses with `JsonApiError`, so the refusal renders through the same central `onError` as every other
|
|
73
|
+
failure. `@zeroxsolutions/jsonapi`'s README carries the two decisions the headers alone do not settle -
|
|
74
|
+
why `Content-Type` is read on a write only, and why an empty `Accept` is refused.
|
|
75
|
+
|
|
76
|
+
## One error seat
|
|
77
|
+
|
|
78
|
+
`createErrorHandler` is the app's `onError`, and every failure renders through it as one
|
|
79
|
+
`application/vnd.api+json` error document carrying `jsonapi: { version: '1.1' }`. It asks, in order:
|
|
80
|
+
|
|
81
|
+
1. `resolve(err, c)` - where a surface's domain error map answers, through
|
|
82
|
+
`@zeroxsolutions/response`'s `createDomainErrorResolver`;
|
|
83
|
+
2. a request the validation hook rejected, one error object per issue;
|
|
84
|
+
3. a `JsonApiError`, rendered as it was thrown;
|
|
85
|
+
4. otherwise the status alone - a hono `HTTPException` at its own, anything else as a 500, written to
|
|
86
|
+
the log first because that 500 carries nothing of the error.
|
|
87
|
+
|
|
88
|
+
The `requestId` option's value becomes the `id` of each error object that carries none, so a client
|
|
89
|
+
quotes the id the request's log lines carry.
|
|
90
|
+
|
|
91
|
+
`createValidationHook` throws its rejection rather than answering, which is what puts a 400 or 422 on
|
|
92
|
+
that same path. It is still passed to the `OpenAPIHono` constructor, because without a hook
|
|
93
|
+
`@hono/zod-validator` (0.9.0) answers a rejected parse with its own raw 400. And it needs
|
|
94
|
+
`createErrorHandler` on the same app: hono answers an error no `onError` catches with a text 500.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// entrypoints/http/app.ts
|
|
98
|
+
const app = new OpenAPIHono<AppEnvironment>({ defaultHook: createValidationHook({ namespace: 'api' }) });
|
|
99
|
+
app.onError(
|
|
100
|
+
createErrorHandler({
|
|
101
|
+
namespace: 'api',
|
|
102
|
+
category: LOG_CATEGORY,
|
|
103
|
+
resolve: createDomainErrorResolver(apiErrors),
|
|
104
|
+
requestId: (c) => c.var.requestId,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Why a binder rather than a ready middleware
|
|
110
|
+
|
|
111
|
+
The schema and the log category are the package's, not the library's: the schema is what the transport
|
|
112
|
+
declares its `Bindings` from, and the category names that worker's isolate. A ready middleware would
|
|
113
|
+
have to read both from somewhere global. Binding them once, at the module that owns them, keeps the
|
|
114
|
+
type flowing (`createEnvironment<AppEnvironment>`) and leaves the mount site the shape a middleware
|
|
115
|
+
module already has - a factory named for its file, called where it is mounted.
|
|
116
|
+
|
|
117
|
+
## API reference
|
|
118
|
+
|
|
119
|
+
The version-accurate API **is the shipped types** - every export carries TSDoc compiled into
|
|
120
|
+
`dist/**/*.d.ts`. Read it in your editor or at `node_modules/@zeroxsolutions/server/dist/`. This README
|
|
121
|
+
does not restate the API: the types are the source of truth and never drift from the version.
|
|
122
|
+
|
|
123
|
+
## Building & testing
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
pnpm nx build @zeroxsolutions/server
|
|
127
|
+
pnpm nx test @zeroxsolutions/server
|
|
128
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
3
|
+
/**
|
|
4
|
+
* Hono adapter over {@link negotiateJsonApi}: rejects with 415/406 per JSON:API v1.1 content
|
|
5
|
+
* negotiation, then stamps the JSON:API media type onto the response `Content-Type`.
|
|
6
|
+
*
|
|
7
|
+
* `Content-Type` is read only where the request carries content - a bodyless `Request` has `body === null`,
|
|
8
|
+
* a write's is a stream - because a header describes a representation and a read route has none, so an
|
|
9
|
+
* unconditional read would cost a client sending a blanket default the whole collection.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* app.use('*', jsonApiContentNegotiation({ namespace: 'api' })); // one middleware, before the routes
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function jsonApiContentNegotiation(options: SurfaceVocabulary): MiddlewareHandler;
|
|
17
|
+
//# sourceMappingURL=content-negotiation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-negotiation.d.ts","sourceRoot":"","sources":["../src/content-negotiation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAQvF"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { negotiateJsonApi } from '@zeroxsolutions/jsonapi';
|
|
2
|
+
import { JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
|
|
3
|
+
/**
|
|
4
|
+
* Hono adapter over {@link negotiateJsonApi}: rejects with 415/406 per JSON:API v1.1 content
|
|
5
|
+
* negotiation, then stamps the JSON:API media type onto the response `Content-Type`.
|
|
6
|
+
*
|
|
7
|
+
* `Content-Type` is read only where the request carries content - a bodyless `Request` has `body === null`,
|
|
8
|
+
* a write's is a stream - because a header describes a representation and a read route has none, so an
|
|
9
|
+
* unconditional read would cost a client sending a blanket default the whole collection.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* app.use('*', jsonApiContentNegotiation({ namespace: 'api' })); // one middleware, before the routes
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function jsonApiContentNegotiation(options) {
|
|
17
|
+
return async (c, next) => {
|
|
18
|
+
const contentType = c.req.raw.body === null ? undefined : c.req.header('Content-Type');
|
|
19
|
+
const error = negotiateJsonApi(contentType, c.req.header('Accept'), options.namespace);
|
|
20
|
+
if (error)
|
|
21
|
+
throw error;
|
|
22
|
+
await next();
|
|
23
|
+
c.header('Content-Type', JSON_API_MEDIA_TYPE);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Env, MiddlewareHandler } from 'hono';
|
|
2
|
+
import type { ZodType } from 'zod';
|
|
3
|
+
/** What {@link createEnvironment} binds to: the schema a worker's bindings must match, and the category naming its isolate. */
|
|
4
|
+
export interface EnvironmentOptions {
|
|
5
|
+
/** Parsed against `c.env`. Its inferred type is what the transport declares its `Bindings` from. */
|
|
6
|
+
schema: ZodType;
|
|
7
|
+
/** Handed to `getLogger` once, when the middleware is built rather than on each request. */
|
|
8
|
+
category: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Binds the environment gate to one package's schema and category, returning that package's own
|
|
12
|
+
* `environment` factory - the gate refuses a request whose `c.env` does not match `schema`, before any
|
|
13
|
+
* other middleware reads a field, so a missing binding is named once rather than at first use.
|
|
14
|
+
*
|
|
15
|
+
* It returns a FACTORY rather than the middleware: a middleware module exports a factory named for its
|
|
16
|
+
* file and the mount site calls it, so `app.use('*', environment())` is what a transport still writes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createEnvironment<E extends Env = Env>({ schema, category, }: EnvironmentOptions): () => MiddlewareHandler<E>;
|
|
19
|
+
//# sourceMappingURL=environment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"environment.d.ts","sourceRoot":"","sources":["../src/environment.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAKnD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAEnC,+HAA+H;AAC/H,MAAM,WAAW,kBAAkB;IACjC,oGAAoG;IACpG,MAAM,EAAE,OAAO,CAAC;IAChB,4FAA4F;IAC5F,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG,EAAE,EACrD,MAAM,EACN,QAAQ,GACT,EAAE,kBAAkB,GAAG,MAAM,iBAAiB,CAAC,CAAC,CAAC,CAoBjD"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getLogger } from '@logtape/logtape';
|
|
2
|
+
import { createMiddleware } from 'hono/factory';
|
|
3
|
+
/**
|
|
4
|
+
* Binds the environment gate to one package's schema and category, returning that package's own
|
|
5
|
+
* `environment` factory - the gate refuses a request whose `c.env` does not match `schema`, before any
|
|
6
|
+
* other middleware reads a field, so a missing binding is named once rather than at first use.
|
|
7
|
+
*
|
|
8
|
+
* It returns a FACTORY rather than the middleware: a middleware module exports a factory named for its
|
|
9
|
+
* file and the mount site calls it, so `app.use('*', environment())` is what a transport still writes.
|
|
10
|
+
*/
|
|
11
|
+
export function createEnvironment({ schema, category, }) {
|
|
12
|
+
const logger = getLogger(category);
|
|
13
|
+
return () => createMiddleware(async (c, next) => {
|
|
14
|
+
const parsed = schema.safeParse(c.env);
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
// Paths only: the field names are the whole diagnostic, and zod 4.4.3 putting no failing value
|
|
17
|
+
// on an issue is that version's property, not something a log sink could take back.
|
|
18
|
+
logger.error('app.env.invalid {requestId} {fields}', {
|
|
19
|
+
requestId: c.var.requestId,
|
|
20
|
+
fields: parsed.error.issues.map((issue) => issue.path.join('.')),
|
|
21
|
+
});
|
|
22
|
+
// Carries no field name: which input an operator failed to set is not the caller's business,
|
|
23
|
+
// and a transport's `onError` renders this as a bare 500.
|
|
24
|
+
throw new Error('environment is not configured');
|
|
25
|
+
}
|
|
26
|
+
// `parsed.data` goes unused because every consumer reads `c.env`, the raw object.
|
|
27
|
+
await next();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context, ErrorHandler } from 'hono';
|
|
2
|
+
import { type ResolvedError } from '@zeroxsolutions/response';
|
|
3
|
+
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
4
|
+
/** Tunes {@link createErrorHandler} for a specific transport (the edge gateway vs a service). */
|
|
5
|
+
export interface ErrorHandlerOptions extends SurfaceVocabulary {
|
|
6
|
+
/**
|
|
7
|
+
* The ISOLATE's own logging category, which the unhandled-500 line is written under. Required
|
|
8
|
+
* rather than defaulted: a category the isolate did not configure is silent, and silence here
|
|
9
|
+
* looks the same as a request that never failed.
|
|
10
|
+
*/
|
|
11
|
+
category: readonly string[];
|
|
12
|
+
/** Resolves a transport's own error type first; `undefined` falls through to the built-in dispatch. */
|
|
13
|
+
resolve?: (err: Error, c: Context) => ResolvedError | undefined;
|
|
14
|
+
/** A request id stamped onto each error object's `id` when it carries none, so a client can correlate. */
|
|
15
|
+
requestId?: (c: Context) => string | undefined | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The one central `onError` a transport installs, rendering every failure as one `application/vnd.api+json`
|
|
19
|
+
* `errors[]` document. It asks, in order: `resolve`, then a request the validation hook rejected, then a
|
|
20
|
+
* {@link JsonApiError} as thrown, then the HTTP status alone.
|
|
21
|
+
*
|
|
22
|
+
* A failure none of those claimed is written to the log before it answers, because the generic 500 it renders
|
|
23
|
+
* carries nothing of the error itself.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createErrorHandler(options: ErrorHandlerOptions): ErrorHandler;
|
|
26
|
+
//# sourceMappingURL=error-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-handler.d.ts","sourceRoot":"","sources":["../src/error-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAGlD,OAAO,EAOL,KAAK,aAAa,EACnB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAuBjE,iGAAiG;AACjG,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D;;;;OAIG;IACH,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,uGAAuG;IACvG,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,KAAK,aAAa,GAAG,SAAS,CAAC;IAChE,0GAA0G;IAC1G,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC;CACvD;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAkC7E"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getLogger } from '@logtape/logtape';
|
|
2
|
+
import { HTTPException } from 'hono/http-exception';
|
|
3
|
+
import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE, toErrorDocument, toErrorObject, } from '@zeroxsolutions/response';
|
|
4
|
+
import { RequestInvalid } from './request-invalid.js';
|
|
5
|
+
// The `code` and `title` each HTTP status is answered with, from the first entry the base registry gives it.
|
|
6
|
+
const META_BY_STATUS = new Map();
|
|
7
|
+
for (const [name, meta] of Object.entries(BASE_ERROR_CODES)) {
|
|
8
|
+
if (!META_BY_STATUS.has(meta.status)) {
|
|
9
|
+
META_BY_STATUS.set(meta.status, { code: name.toLowerCase(), title: meta.title });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Projects a failure hono raised - or one nothing claimed - onto a JSON:API error object by HTTP status.
|
|
13
|
+
function errorObjectByStatus(err, namespace) {
|
|
14
|
+
const status = err instanceof HTTPException ? err.status : 500;
|
|
15
|
+
const meta = META_BY_STATUS.get(status) ?? { code: 'error', title: 'Error' };
|
|
16
|
+
const detail = err instanceof HTTPException ? err.message : 'Internal server error';
|
|
17
|
+
return {
|
|
18
|
+
status: String(status),
|
|
19
|
+
code: `${namespace}.${meta.code}`,
|
|
20
|
+
title: meta.title,
|
|
21
|
+
...(detail && detail !== meta.title ? { detail } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The one central `onError` a transport installs, rendering every failure as one `application/vnd.api+json`
|
|
26
|
+
* `errors[]` document. It asks, in order: `resolve`, then a request the validation hook rejected, then a
|
|
27
|
+
* {@link JsonApiError} as thrown, then the HTTP status alone.
|
|
28
|
+
*
|
|
29
|
+
* A failure none of those claimed is written to the log before it answers, because the generic 500 it renders
|
|
30
|
+
* carries nothing of the error itself.
|
|
31
|
+
*/
|
|
32
|
+
export function createErrorHandler(options) {
|
|
33
|
+
const logger = getLogger(options.category);
|
|
34
|
+
return (err, c) => {
|
|
35
|
+
const requestId = options.requestId?.(c);
|
|
36
|
+
const stamp = (e) => (requestId && !e.id ? { ...e, id: requestId } : e);
|
|
37
|
+
const claimed = options.resolve?.(err, c) ??
|
|
38
|
+
(err instanceof RequestInvalid ? { status: err.status, errors: err.errors } : undefined) ??
|
|
39
|
+
(err instanceof JsonApiError ? { status: err.status, errors: [toErrorObject(err)] } : undefined);
|
|
40
|
+
if (!claimed && !(err instanceof HTTPException)) {
|
|
41
|
+
logger.error('http.error.unhandled {method} {path} {type}', {
|
|
42
|
+
// Undefined where a transport never mounted `requestId()` - minting one here is a second id for one request.
|
|
43
|
+
requestId: requestId ?? c.get('requestId'),
|
|
44
|
+
method: c.req.method,
|
|
45
|
+
// `path`, not `url`: a query string is where a caller puts a token, and a sink keeps what it is given.
|
|
46
|
+
path: c.req.path,
|
|
47
|
+
type: err.name,
|
|
48
|
+
message: err.message,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const resolved = claimed ?? {
|
|
52
|
+
status: err instanceof HTTPException ? err.status : 500,
|
|
53
|
+
errors: [errorObjectByStatus(err, options.namespace)],
|
|
54
|
+
};
|
|
55
|
+
const doc = toErrorDocument(resolved.errors.map(stamp));
|
|
56
|
+
return c.body(JSON.stringify(doc), resolved.status, {
|
|
57
|
+
'Content-Type': JSON_API_MEDIA_TYPE,
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Hono handler the house ships - the bindings gate, and the three the wire spelling decides.
|
|
3
|
+
*/
|
|
4
|
+
export { createEnvironment, type EnvironmentOptions } from './environment.js';
|
|
5
|
+
export type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
6
|
+
export { jsonApiContentNegotiation } from './content-negotiation.js';
|
|
7
|
+
export { createErrorHandler, type ErrorHandlerOptions } from './error-handler.js';
|
|
8
|
+
export { createValidationHook } from './validation-hook.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAClF,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Hono handler the house ships - the bindings gate, and the three the wire spelling decides.
|
|
3
|
+
*/
|
|
4
|
+
export { createEnvironment } from './environment.js';
|
|
5
|
+
export { jsonApiContentNegotiation } from './content-negotiation.js';
|
|
6
|
+
export { createErrorHandler } from './error-handler.js';
|
|
7
|
+
export { createValidationHook } from './validation-hook.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ErrorObject } from '@zeroxsolutions/response';
|
|
2
|
+
/**
|
|
3
|
+
* A request its route's own schema rejected, thrown by the validation hook so the app's one `onError` renders it.
|
|
4
|
+
*
|
|
5
|
+
* The package root does not list it: the hook is its only producer and the error handler its only reader.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RequestInvalid extends Error {
|
|
8
|
+
readonly status: 400 | 422;
|
|
9
|
+
readonly errors: ErrorObject[];
|
|
10
|
+
constructor(status: 400 | 422, errors: ErrorObject[]);
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=request-invalid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-invalid.d.ts","sourceRoot":"","sources":["../src/request-invalid.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAE5D;;;;GAIG;AACH,qBAAa,cAAe,SAAQ,KAAK;IAErC,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG;IAC1B,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE;gBADrB,MAAM,EAAE,GAAG,GAAG,GAAG,EACjB,MAAM,EAAE,WAAW,EAAE;CAKjC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request its route's own schema rejected, thrown by the validation hook so the app's one `onError` renders it.
|
|
3
|
+
*
|
|
4
|
+
* The package root does not list it: the hook is its only producer and the error handler its only reader.
|
|
5
|
+
*/
|
|
6
|
+
export class RequestInvalid extends Error {
|
|
7
|
+
status;
|
|
8
|
+
errors;
|
|
9
|
+
constructor(status, errors) {
|
|
10
|
+
super('The request does not match its route schema');
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.errors = errors;
|
|
13
|
+
this.name = 'RequestInvalid';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** What every code a surface renders is prefixed with, so one surface answers in one vocabulary. */
|
|
2
|
+
export interface SurfaceVocabulary {
|
|
3
|
+
/**
|
|
4
|
+
* The transport's own namespace - `api`, `back_office` - prefixed onto every code these handlers mint.
|
|
5
|
+
*
|
|
6
|
+
* Required rather than defaulted: a handler answering `bad_request` beside a route map answering
|
|
7
|
+
* `api.place.not_found` publishes two vocabularies, and a client cannot tell which surface spoke.
|
|
8
|
+
*/
|
|
9
|
+
namespace: string;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=surface-vocabulary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surface-vocabulary.d.ts","sourceRoot":"","sources":["../src/surface-vocabulary.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Hook } from '@hono/zod-openapi';
|
|
2
|
+
import type { Env } from 'hono';
|
|
3
|
+
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
4
|
+
/**
|
|
5
|
+
* The `defaultHook` an `OpenAPIHono` is constructed with, rejecting a request its own schema refuses as one error
|
|
6
|
+
* object per issue - 422 for a `json` or `form` target, 400 otherwise.
|
|
7
|
+
*
|
|
8
|
+
* It throws rather than answering, so the rejection renders through the app's `createErrorHandler` like every other
|
|
9
|
+
* failure, carrying the request id that handler stamps; an app without that handler answers the throw with hono's
|
|
10
|
+
* text 500. @hono/zod-validator 0.9.0 lets a hook's throw through, and answers `c.json(result, 400)` itself unless a
|
|
11
|
+
* hook answers or throws first - which is why the hook is still passed to the constructor.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createValidationHook<E extends Env = Env>(options: SurfaceVocabulary): Hook<unknown, E, string, undefined>;
|
|
14
|
+
//# sourceMappingURL=validation-hook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation-hook.d.ts","sourceRoot":"","sources":["../src/validation-hook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAGhC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAoBjE;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG,EACtD,OAAO,EAAE,iBAAiB,GACzB,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAoBrC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { BASE_ERROR_CODES } from '@zeroxsolutions/response';
|
|
2
|
+
import { RequestInvalid } from './request-invalid.js';
|
|
3
|
+
/**
|
|
4
|
+
* The error object's `source` for one issue, by the slot it was read from.
|
|
5
|
+
*
|
|
6
|
+
* The spec defines exactly three members, and a path segment matches none of them: `pointer` is a JSON
|
|
7
|
+
* Pointer into the request DOCUMENT, `parameter` a URI query parameter, `header` a header name. So a
|
|
8
|
+
* `param` or `cookie` issue names its slot in `meta` rather than borrowing a member that means
|
|
9
|
+
* something else.
|
|
10
|
+
*/
|
|
11
|
+
function sourceFor(target, path) {
|
|
12
|
+
if (target === 'json' || target === 'form')
|
|
13
|
+
return { source: { pointer: `/${path.split('.').join('/')}` } };
|
|
14
|
+
if (target === 'query')
|
|
15
|
+
return { source: { parameter: path } };
|
|
16
|
+
if (target === 'header')
|
|
17
|
+
return { source: { header: path } };
|
|
18
|
+
return { meta: { target, path } };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The `defaultHook` an `OpenAPIHono` is constructed with, rejecting a request its own schema refuses as one error
|
|
22
|
+
* object per issue - 422 for a `json` or `form` target, 400 otherwise.
|
|
23
|
+
*
|
|
24
|
+
* It throws rather than answering, so the rejection renders through the app's `createErrorHandler` like every other
|
|
25
|
+
* failure, carrying the request id that handler stamps; an app without that handler answers the throw with hono's
|
|
26
|
+
* text 500. @hono/zod-validator 0.9.0 lets a hook's throw through, and answers `c.json(result, 400)` itself unless a
|
|
27
|
+
* hook answers or throws first - which is why the hook is still passed to the constructor.
|
|
28
|
+
*/
|
|
29
|
+
export function createValidationHook(options) {
|
|
30
|
+
return (result) => {
|
|
31
|
+
if (result.success)
|
|
32
|
+
return;
|
|
33
|
+
const target = result.target;
|
|
34
|
+
const body = target === 'json' || target === 'form';
|
|
35
|
+
const { status, title } = body ? BASE_ERROR_CODES.UNPROCESSABLE_ENTITY : BASE_ERROR_CODES.BAD_REQUEST;
|
|
36
|
+
const code = `${options.namespace}.${body ? 'unprocessable_entity' : 'bad_request'}`;
|
|
37
|
+
const errors = result.error.issues.map((issue) => ({
|
|
38
|
+
status: String(status),
|
|
39
|
+
code,
|
|
40
|
+
title,
|
|
41
|
+
detail: issue.message,
|
|
42
|
+
// A whole-object refinement carries an empty path, so the slot it was read from stands in.
|
|
43
|
+
...sourceFor(target, issue.path.map(String).join('.') || target),
|
|
44
|
+
}));
|
|
45
|
+
throw new RequestInvalid(status, errors);
|
|
46
|
+
};
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zeroxsolutions/server",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"description": "Every Hono handler the ZeroXSolutions house ships: the environment gate bound to a worker's own bindings schema, the central onError, the OpenAPIHono defaultHook and the JSON:API content-negotiation middleware - kept out of the packages that decide what they do, so those carry no framework peer.",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"!**/*.tsbuildinfo"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"tslib": "^2.3.0",
|
|
24
|
+
"@zeroxsolutions/jsonapi": "0.11.0",
|
|
25
|
+
"@zeroxsolutions/response": "0.12.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@hono/zod-openapi": "^1.0.0",
|
|
29
|
+
"@logtape/logtape": "^2.0.0",
|
|
30
|
+
"hono": "^4.0.0",
|
|
31
|
+
"zod": "^4.3.6"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@hono/zod-openapi": "^1.4.0",
|
|
35
|
+
"@logtape/logtape": "^2.3.2",
|
|
36
|
+
"hono": "^4.12.27",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"nx": {
|
|
40
|
+
"name": "@zeroxsolutions/server"
|
|
41
|
+
},
|
|
42
|
+
"main": "./dist/index.js",
|
|
43
|
+
"module": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.ts"
|
|
45
|
+
}
|