@owlmeans/server-api 0.1.18-rc.24 → 0.1.18-rc.26

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 CHANGED
@@ -1,78 +1,212 @@
1
1
  # @owlmeans/server-api
2
2
 
3
- Fastify-based HTTP/WebSocket server with typed handler factories for the OwlMeans entrypoint system.
4
-
5
- ## Overview
6
-
7
- - `handlers<Context>()` creates protocol-bound `body()`, `params()`, `request()` and upload handlers
8
- - Built on [Fastify](https://fastify.dev/) — registered entrypoints become Fastify routes automatically
9
- - `createApiServer` / `appendApiServer` initialize the HTTP server in a context
10
- - Not typically used directly — import handlers from `@owlmeans/server-app`
3
+ The Fastify HTTP server of an OwlMeans backend and the protocol-bound handler factories that serve
4
+ it. An application imports `handlers<Context>()` in every HTTP handler module (often through the
5
+ `@owlmeans/server-app` re-export) and `holdApiPort` for a guarded boot. The server itself —
6
+ `appendApiServer` — is already added by `@owlmeans/server-app`'s `makeContext`; call it directly
7
+ only when composing a context without that package. WebSocket routes belong to
8
+ `@owlmeans/server-socket`, and calls to other services go through the `@owlmeans/api` client.
11
9
 
12
10
  ## Installation
13
11
 
14
12
  ```bash
15
- bun add @owlmeans/server-api@^0.1.18-rc.16
13
+ bun add @owlmeans/server-api@^0.1.18-rc.25
16
14
  ```
17
15
 
16
+ ## Concepts
17
+
18
+ - **API server** — the `api-server` service (`context.getApiServer()`), holding a `FastifyInstance`
19
+ and `listen()`. At context init it builds its route table from the registered entrypoints: backend
20
+ routes of this `cfg.service` only, excluding socket and queue protocols.
21
+ - **Request pipeline** — intermediates run first; then the protocol's guards (the first guard whose
22
+ `match` hits handles the request and sets `request.auth`, and the organization entity is attached);
23
+ then its gates (`gate.assert`); then the handler.
24
+ - **Protocol-bound handler** — `handlers<Context>().body / .params / .request` infer the request
25
+ sections and the response type from the declaration. A returned value resolves the entrypoint with
26
+ `EntrypointOutcome.Ok` (HTTP 200); a thrown error rejects it.
27
+ - **Validation** — the contract's schemas are compiled with AJV (`removeAdditional`, `useDefaults`,
28
+ `coerceTypes`), so a handler receives cleaned, coerced input.
29
+ - **Error mapping** — `AuthForbidden` / `AccessError` answer 403, `AuthorizationError` /
30
+ `AuthFailedError` answer 401, anything else 500, with the marshalled `ResilientError` as the body.
31
+
18
32
  ## Usage
19
33
 
20
- Handlers are derived from the shared protocol and attached with `bind()`:
34
+ ### Body, params and request handlers
21
35
 
22
- ```typescript
36
+ ```ts
23
37
  import { handlers } from '@owlmeans/server-api'
24
38
  import { bind } from '@owlmeans/server-entrypoint'
25
- import { projectEntrypoints } from 'project-common/entrypoints'
39
+ import { requireEntityKey } from '@owlmeans/auth-common'
40
+ import { projectProtocols } from 'my-app-common'
41
+ import type { Context } from './context.js'
26
42
 
27
43
  const api = handlers<Context>()
28
44
 
29
- // Body handler: receives parsed + validated body as first arg
30
- const create = api.body(projectEntrypoints.create, async (payload, context, req) => {
31
- const ctx = context as Context
32
- return await ctx.project().create({ ...payload, entityId: req.entity!.id })
33
- })
45
+ // body: the validated body first, then context and the full request
46
+ const create = api.body(projectProtocols.create, async (payload, context, request) =>
47
+ context.project().create({ ...payload, entityId: requireEntityKey(request) }))
34
48
 
35
- // Params handler: receives validated URL params as first arg
36
- const get = api.params(projectEntrypoints.get, async (params, context, req) => {
37
- return await (context as Context).project().get(params.id)
38
- })
49
+ // params: the validated URL params first
50
+ const get = api.params(projectProtocols.get, async ({ id }, context, request) =>
51
+ context.project().load({ id, entityId: requireEntityKey(request) }))
39
52
 
40
- // Request handler: receives the full AbstractRequest
41
- const health = api.request(projectEntrypoints.health, async (req, context) => {
42
- return { status: 'ok' }
43
- })
53
+ // request: every typed section plus request metadata (auth, entity, headers)
54
+ const search = api.request(projectProtocols.search, async (request, context) =>
55
+ context.project().list({ entityId: requireEntityKey(request), ...request.query }))
44
56
 
45
- export const entrypoints = [
46
- bind(projectEntrypoints.create, create),
47
- bind(projectEntrypoints.get, get),
48
- bind(projectEntrypoints.health, health),
57
+ export const serverBindings = [
58
+ bind(projectProtocols.base),
59
+ bind(projectProtocols.create, create),
60
+ bind(projectProtocols.get, get),
61
+ bind(projectProtocols.search, search),
49
62
  ]
50
63
  ```
51
64
 
52
- ## API
65
+ ### Refusing a request
66
+
67
+ Guards and gates run before the handler; a handler still checks ownership and throws a typed error,
68
+ which the server maps onto a status.
69
+
70
+ ```ts
71
+ import { AuthForbidden } from '@owlmeans/auth'
72
+
73
+ // the protocol declares both `params` and `body`
74
+ export const rename = api.body(projectProtocols.rename, async ({ name }, context, request) => {
75
+ const entityId = requireEntityKey(request)
76
+ const project = await context.project().load({ id: request.params.id, entityId })
77
+ if (project == null) {
78
+ throw new AuthForbidden('project') // 403
79
+ }
80
+
81
+ return context.project().update({ ...project, name })
82
+ })
83
+ ```
84
+
85
+ ### File upload
53
86
 
54
- ### `handlers<Context>()`
87
+ `uploadedFile(request)` is the only place a handler touches the Fastify multipart API. The protocol
88
+ must declare a `body` section.
55
89
 
56
- Creates factories whose first argument is the protocol declaration, coupling the callback to its
57
- request and response contract.
58
- ```typescript
59
- handlers<Context>().body(protocol, handler)
60
- handlers<Context>().params(protocol, handler)
61
- handlers<Context>().request(protocol, handler)
90
+ ```ts
91
+ import { handlers, NoFileError, uploadedFile } from '@owlmeans/server-api'
92
+
93
+ export const upload = api.request(invoiceProtocols.attach, async (request, context) => {
94
+ const file = await uploadedFile(request)
95
+ if (file == null) {
96
+ throw new NoFileError()
97
+ }
98
+
99
+ const content = await file.toBuffer()
100
+
101
+ return context.attachment().create({
102
+ entityId: requireEntityKey(request),
103
+ name: file.filename,
104
+ mimetype: file.mimetype,
105
+ size: content.length,
106
+ })
107
+ })
108
+ ```
109
+
110
+ ### Holding the port during boot
111
+
112
+ ```ts
113
+ import { holdApiPort } from '@owlmeans/server-api'
114
+
115
+ const hold = await holdApiPort(cfg, { okPath: '/healthz' }) // 200 on /healthz, 503 elsewhere
116
+ await context.configure().init()
117
+ await hold.release()
118
+ await context.getApiServer().listen()
62
119
  ```
63
120
 
64
- Each callback returns the response value; thrown `ResilientError` subclasses are mapped by the
65
- server. `uploadedFile(request)` remains the multipart boundary.
121
+ ### A context without `@owlmeans/server-app`
122
+
123
+ ```ts
124
+ import { makeServerContext } from '@owlmeans/server-context'
125
+ import { appendApiServer } from '@owlmeans/server-api'
66
126
 
67
- ### `extractUploadedFile(req, fieldName): UploadedFile | null`
127
+ const context = appendApiServer(makeServerContext(cfg))
128
+ context.registerEntrypoints(serverBindings)
129
+ await context.configure().init()
130
+
131
+ // the Fastify instance, for plugins the framework does not register
132
+ context.getApiServer().server.addHook('onResponse', async (request, reply) => {
133
+ console.log(request.url, reply.statusCode)
134
+ })
68
135
 
69
- Extract a multipart-uploaded file from the request.
136
+ await context.getApiServer().listen()
137
+ ```
138
+
139
+ ## API
70
140
 
71
- ## Related Packages
141
+ ### Root export
142
+
143
+ | Symbol | Kind | Purpose |
144
+ |---|---|---|
145
+ | `handlers<Context>()` | function | Factories `body(protocol, fn)`, `params(protocol, fn)`, `request(protocol, fn)` returning `BoundEntrypointHandler<Protocol>` |
146
+ | `uploadedFile(request)` | function | The multipart file of a request, or `undefined` |
147
+ | `extractUploadedFile(req)` | function | Same boundary for an untyped `AbstractRequest`; returns `Promise<UploadedFile \| undefined>` |
148
+ | `holdApiPort(cfg, opts?)` | function | Bind the service port before init; returns `ApiPortHold` with `release()` |
149
+ | `createApiServer(alias)` | function | Create the Fastify API server service |
150
+ | `appendApiServer(ctx, alias = DEFAULT_ALIAS)` | function | Register the API server and `context.getApiServer()` |
151
+ | `handleBody`, `handleParams`, `handleRequest`, `handleIntermediate` | function | Unbound compatibility wrappers returning `RefedEntrypointHandler` |
152
+ | `AuthFailedError`, `AccessError`, `NoFileError` | class | Registered `ResilientError` subclasses (401, 403, 500) |
153
+ | `DEFAULT_ALIAS` | const | `'api-server'` |
154
+ | `PORT` | const | `80`, used when the service route declares no port |
155
+ | `CLOSED_HOST`, `OPENED_HOST` | const | `'127.0.0.1'` and `'0.0.0.0'`; a service with `opened: true` listens on the second |
156
+ | `ApiServer`, `ApiServerAppend` | type | The server service and its context mixin |
157
+ | `ApiPortHold`, `ApiPortHoldOptions` | type | `holdApiPort` result and options (`okPath?`, `payload?`) |
158
+ | `Config`, `Context` | type | `ServerConfig` and `ServerContext & ApiServerAppend` |
159
+ | `Request`, `Response` | type | Fastify request and reply |
160
+ | `UploadedFile` | type | Fastify `MultipartFile` |
161
+
162
+ ### `./utils` subpath
163
+
164
+ For transport packages (such as `@owlmeans/server-socket`) that reuse the HTTP pipeline; application
165
+ code does not need it.
166
+
167
+ | Symbol | Kind | Purpose |
168
+ |---|---|---|
169
+ | `authorize(context, module, req, reply)` | function | Run guards, set `auth`, call `attachEntity` |
170
+ | `createServerHandler(module, location)` | function | The Fastify handler: authorize, gates, handle, respond |
171
+ | `canServeModule(context, module)` | function | Whether an entrypoint belongs on this HTTP server |
172
+ | `provideRequest(alias, req, provision?)` | function | Build an `AbstractRequest` from a Fastify request |
173
+ | `executeResponse(response, reply, throwOnError?)` | function | Send an `AbstractResponse` onto a reply |
174
+ | `handleError(error, reply)` | function | Map an error onto 401 / 403 / 500 |
175
+ | `fixFormatDates(schema)` | function | Rewrite `date-time` object schemas as strings |
176
+ | `populateContext(req, context)`, `extractContext(req, ctx?, location?)` | function | Carry the request-scoped context on the raw request |
177
+
178
+ ## Common pitfalls
179
+
180
+ - `.body` and `.params` exist only for a protocol that declares that section; for anything else use
181
+ `.request`.
182
+ - Do not reach into `request.original` from application code — `uploadedFile(request)` is the
183
+ multipart boundary.
184
+ - Prefer `handlers<Context>()` over the unbound `handle*` wrappers, and never hand-write a
185
+ `RefedEntrypointHandler` for an HTTP route.
186
+ - A handler is wrapped by `handlers<Context>()` **exactly once**: wrap a plain function where it is
187
+ bound (`bind(p, api.body(p, create))`), or bind the already-bound export directly
188
+ (`const create = api.body(p, ...)` then `bind(p, create)`) — never both. `tsc` rejects a double
189
+ wrap (`TS2345 "BoundEntrypointHandler<…> is not assignable"`); at runtime a handler already bound
190
+ to the SAME protocol is returned unchanged with a one-time console warning, and anything else that
191
+ is not a plain function fails that one route with `HandlerMisconfiguredError` instead of the
192
+ opaque `TypeError: handler is not a function`.
193
+ - A guard only authenticates. Gates authorize, and the handler keeps its organization check keyed on
194
+ `requireEntityKey(request)` — never an id read from the token.
195
+ - `request.entity` is absent when no entity resolver is registered; `requireEntityKey` falls back to
196
+ the slug there, `requireEntity` throws.
197
+ - A handler always answers 200 on success. The port comes from `cfg.services[cfg.service]`
198
+ (`internalPort ?? port ?? 80`).
199
+ - A `holdApiPort` bind failure must end the process with a non-zero exit.
200
+ - WebSocket routes use `connection(protocol, callback)` from `@owlmeans/server-socket`.
201
+
202
+ ## Related packages
72
203
 
73
- - [`@owlmeans/server-app`](../server-app) — application bootstrap and convenience re-exports
74
204
  - [`@owlmeans/server-entrypoint`](../server-entrypoint) — `bind()` attaches handlers to protocols
75
- - [`@owlmeans/server-socket`](../server-socket) — WebSocket `connection` counterpart
205
+ - [`@owlmeans/server-app`](../server-app) — application bootstrap and re-exports
206
+ - [`@owlmeans/server-socket`](../server-socket) — the WebSocket counterpart of `handlers`
207
+ - [`@owlmeans/entrypoint`](../entrypoint) — `contract`, `typed`, request/response types
208
+ - [`@owlmeans/auth-common`](../auth-common) — `requireEntityKey`, `attachEntity`, guard aliases
209
+ - [`@owlmeans/api`](../api) — the client that calls these endpoints from other services
76
210
 
77
211
  <!-- owlmeans:agent-guidance:start -->
78
212
  ## Agent guidance
@@ -82,7 +216,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
82
216
  your project's skill store (`.agents/skills/`):
83
217
 
84
218
  ```sh
85
- npx @owlmeans/agent-skills@^0.1.18-rc.19
219
+ npx @owlmeans/agent-skills@^0.1.18-rc.21
86
220
  ```
87
221
 
88
222
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-api",
4
- "version": "0.1.18-rc.24",
5
- "generatedAt": "2026-09-12T13:51:53.520Z",
4
+ "version": "0.1.18-rc.26",
5
+ "generatedAt": "2026-09-15T12:21:31.872Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -7,7 +7,7 @@ user-invocable: false
7
7
 
8
8
  # @owlmeans/server-api
9
9
 
10
- **Install:** `bun add @owlmeans/server-api@^0.1.18-rc.23`
10
+ **Install:** `bun add @owlmeans/server-api@^0.1.18-rc.25`
11
11
 
12
12
  Make handlers from the protocol declaration so input and output types stay coupled to the shared
13
13
  contract:
@@ -15,22 +15,22 @@ contract:
15
15
  ```ts
16
16
  const api = handlers<AppContext>()
17
17
 
18
- const create = api.body(projectEntrypoints.create, async (body, context, request) =>
18
+ const create = api.body(projectProtocols.create, async (body, context, request) =>
19
19
  context.projects.create(body, request.auth)
20
20
  )
21
21
 
22
- const get = api.params(projectEntrypoints.get, async ({ id }, context) =>
22
+ const get = api.params(projectProtocols.get, async ({ id }, context) =>
23
23
  context.projects.get(id)
24
24
  )
25
25
 
26
- const search = api.request(projectEntrypoints.search, async (request, context) =>
26
+ const search = api.request(projectProtocols.search, async (request, context) =>
27
27
  context.projects.search(request.query)
28
28
  )
29
29
 
30
- export const entrypoints = [
31
- bind(projectEntrypoints.create, create),
32
- bind(projectEntrypoints.get, get),
33
- bind(projectEntrypoints.search, search),
30
+ export const serverBindings = [
31
+ bind(projectProtocols.create, create),
32
+ bind(projectProtocols.get, get),
33
+ bind(projectProtocols.search, search),
34
34
  ]
35
35
  ```
36
36
 
@@ -38,6 +38,24 @@ export const entrypoints = [
38
38
  any protocol and receives all its typed sections plus request metadata. A successful callback
39
39
  return resolves the entrypoint with `EntrypointOutcome.Ok`; a thrown error rejects it.
40
40
 
41
+ ## Wrap exactly once
42
+
43
+ A handler is wrapped by `handlers<Context>()` exactly once — either shape above (create the bound
44
+ handler and bind it directly) is correct on its own. Never combine them: a handler module that
45
+ already exports a bound handler must be bound directly, not wrapped again where it is bound.
46
+
47
+ ```ts
48
+ // WRONG — bound once in the handler module, wrapped a second time here
49
+ export const create = api.body(projectProtocols.create, async (body, context) => ...)
50
+ bind(projectProtocols.create, api.body(projectProtocols.create, create))
51
+ ```
52
+
53
+ `tsc` rejects the double wrap (`TS2345 "Argument of type 'BoundEntrypointHandler<…>' is not
54
+ assignable"`). At runtime, `body`/`params`/`request` return an already-bound handler for the SAME
55
+ protocol unchanged, with a one-time warning; anything else that is not a plain function fails only
56
+ that one route with `HandlerMisconfiguredError`, instead of the opaque
57
+ `TypeError: handler is not a function`.
58
+
41
59
  `uploadedFile(request)` is the Fastify multipart boundary. Keep raw Fastify access there rather
42
60
  than reaching through `request.original` in application code.
43
61
 
package/build/errors.d.ts CHANGED
@@ -11,4 +11,16 @@ export declare class NoFileError extends ApiError {
11
11
  static typeName: string;
12
12
  constructor();
13
13
  }
14
+ /**
15
+ * `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
16
+ * and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
17
+ * from inside the bound handler's own request path, so the module still loads and every OTHER
18
+ * route on it still answers; only the misconfigured one reports why, as a 500 rather than the
19
+ * opaque `TypeError: handler is not a function` a double wrap used to fail with. See
20
+ * `handlers()` in `protocol.ts` for what does and does not reach here.
21
+ */
22
+ export declare class HandlerMisconfiguredError extends ApiError {
23
+ static typeName: string;
24
+ constructor(message?: string);
25
+ }
14
26
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAGxC,qBAAa,eAAgB,SAAQ,QAAQ;IAC3C,OAAuB,QAAQ,SAAoB;IAEnD,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAsC;IAErE,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAgB;IAE/C,cAGC;CACF"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAGxC,qBAAa,eAAgB,SAAQ,QAAQ;IAC3C,OAAuB,QAAQ,SAAoB;IAEnD,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAsC;IAErE,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAgB;IAE/C,cAGC;CACF;AAED;;;;;;;GAOG;AACH,qBAAa,yBAA0B,SAAQ,QAAQ;IACrD,OAAuB,QAAQ,SAA8B;IAE7D,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF"}
package/build/errors.js CHANGED
@@ -21,7 +21,23 @@ export class NoFileError extends ApiError {
21
21
  this.type = NoFileError.typeName;
22
22
  }
23
23
  }
24
+ /**
25
+ * `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
26
+ * and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
27
+ * from inside the bound handler's own request path, so the module still loads and every OTHER
28
+ * route on it still answers; only the misconfigured one reports why, as a 500 rather than the
29
+ * opaque `TypeError: handler is not a function` a double wrap used to fail with. See
30
+ * `handlers()` in `protocol.ts` for what does and does not reach here.
31
+ */
32
+ export class HandlerMisconfiguredError extends ApiError {
33
+ static typeName = 'HandlerMisconfiguredError';
34
+ constructor(message = 'error') {
35
+ super(`handler:misconfigured:${message}`);
36
+ this.type = HandlerMisconfiguredError.typeName;
37
+ }
38
+ }
24
39
  ResilientError.registerErrorClass(AuthFailedError);
25
40
  ResilientError.registerErrorClass(AccessError);
26
41
  ResilientError.registerErrorClass(NoFileError);
42
+ ResilientError.registerErrorClass(HandlerMisconfiguredError);
27
43
  //# sourceMappingURL=errors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,OAAO,eAAgB,SAAQ,QAAQ;IACpC,MAAM,CAAU,QAAQ,GAAG,iBAAiB,CAAA;IAEnD,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAA;QACxB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC,QAAQ,CAAA;IACtC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,SAAS,eAAe,CAAC,QAAQ,EAAE,CAAA;IAErE,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;QAC1B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,aAAa,CAAA;IAE/C;QACE,KAAK,CAAC,SAAS,CAAC,CAAA;QAChB,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,cAAc,CAAC,kBAAkB,CAAC,eAAe,CAAC,CAAA;AAClD,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA"}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,OAAO,eAAgB,SAAQ,QAAQ;IACpC,MAAM,CAAU,QAAQ,GAAG,iBAAiB,CAAA;IAEnD,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAA;QACxB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC,QAAQ,CAAA;IACtC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,SAAS,eAAe,CAAC,QAAQ,EAAE,CAAA;IAErE,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;QAC1B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,aAAa,CAAA;IAE/C;QACE,KAAK,CAAC,SAAS,CAAC,CAAA;QAChB,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,yBAA0B,SAAQ,QAAQ;IAC9C,MAAM,CAAU,QAAQ,GAAG,2BAA2B,CAAA;IAE7D,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,yBAAyB,OAAO,EAAE,CAAC,CAAA;QACzC,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC,QAAQ,CAAA;IAChD,CAAC;CACF;AAED,cAAc,CAAC,kBAAkB,CAAC,eAAe,CAAC,CAAA;AAClD,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,yBAAyB,CAAC,CAAA"}
@@ -1,7 +1,26 @@
1
1
  import type { BasicConfig, BasicContext } from '@owlmeans/context';
2
2
  import type { BodyOf, EntrypointProtocolDeclaration, HandlerRequest, OpenValue, ParamsOf, RequestOf, ResponseOf } from '@owlmeans/entrypoint';
3
3
  import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint';
4
- type HandlerResponse<Response> = [Response] extends [undefined] ? void | undefined : Response;
4
+ /**
5
+ * True for a response the declaration left with no compile-time contract: `typed<any>()` (an
6
+ * `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
7
+ * checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
8
+ * a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
9
+ * with no shape to validate a handler's return against — the same signal a zero-argument
10
+ * `contract()` produces for a genuinely response-less route. Neither means "this handler must
11
+ * return void": it means the declaration did not constrain the response, so the handler is free to
12
+ * return whatever its route actually sends.
13
+ */
14
+ type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false;
15
+ /**
16
+ * The type a bound handler is allowed to return for a given protocol response.
17
+ *
18
+ * A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
19
+ * exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
20
+ * declaration made no promise about the response, so nothing here should force a handler bound to
21
+ * it into returning `void`.
22
+ */
23
+ type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response;
5
24
  type MaybePromise<Value> = Value | Promise<Value>;
6
25
  type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends {
7
26
  body: OpenValue;
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAElE,OAAO,KAAK,EACV,MAAM,EAAE,6BAA6B,EAAE,cAAc,EAAe,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAC/G,MAAM,sBAAsB,CAAA;AAE7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;AAEzE,KAAK,eAAe,CAAC,QAAQ,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,QAAQ,CAAA;AAC7F,KAAK,YAAY,CAAC,KAAK,IAAI,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;AACjD,KAAK,YAAY,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAC/G,QAAQ,GAAG,KAAK,CAAA;AACpB,KAAK,cAAc,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAChH,QAAQ,GAAG,KAAK,CAAA;AAyCpB;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,SAAS,YAAY,CAAC,WAAW,CAAC;IAChE,IAAI,GAAG,QAAQ,SAAS,6BAA6B,YACzC,QAAQ,WACT,QAAQ,SAAS,YAAY,CAAC,QAAQ,CAAC,GAAG,CAC/C,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,EACzB,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,MAAM,GAAG,QAAQ,SAAS,6BAA6B,YAC3C,QAAQ,WACT,QAAQ,SAAS,cAAc,CAAC,QAAQ,CAAC,GAAG,CACjD,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAC3B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,OAAO,GAAG,QAAQ,SAAS,6BAA6B,YAC5C,QAAQ,WACT,CACP,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,EAC5C,OAAO,EAAE,OAAO,KACb,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,KACvD,sBAAsB,CAAC,QAAQ,CAAC;CACnC,CAAA;AAEF,sGAAsG;AACtG,eAAO,MAAM,YAAY,YACd,cAAc,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,KACxC,OAAO,CAAC,OAAO,oBAAoB,EAAE,aAAa,GAAG,SAAS,CAKhE,CAAA"}
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAElE,OAAO,KAAK,EACV,MAAM,EAAE,6BAA6B,EAAE,cAAc,EAAe,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAC/G,MAAM,sBAAsB,CAAA;AAE7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;AAGzE;;;;;;;;;GASG;AACH,KAAK,iBAAiB,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,IAAI,GAAG,KAAK,CAAA;AAE7F;;;;;;;GAOG;AACH,KAAK,eAAe,CAAC,QAAQ,IAAI,iBAAiB,CAAC,QAAQ,CAAC,SAAS,IAAI,GAAG,GAAG,GAAG,QAAQ,CAAA;AAC1F,KAAK,YAAY,CAAC,KAAK,IAAI,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;AACjD,KAAK,YAAY,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAC/G,QAAQ,GAAG,KAAK,CAAA;AACpB,KAAK,cAAc,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAChH,QAAQ,GAAG,KAAK,CAAA;AAqGpB;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,SAAS,YAAY,CAAC,WAAW,CAAC;IAChE,IAAI,GAAG,QAAQ,SAAS,6BAA6B,YACzC,QAAQ,WACT,QAAQ,SAAS,YAAY,CAAC,QAAQ,CAAC,GAAG,CAC/C,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,EACzB,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,MAAM,GAAG,QAAQ,SAAS,6BAA6B,YAC3C,QAAQ,WACT,QAAQ,SAAS,cAAc,CAAC,QAAQ,CAAC,GAAG,CACjD,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAC3B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,OAAO,GAAG,QAAQ,SAAS,6BAA6B,YAC5C,QAAQ,WACT,CACP,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,EAC5C,OAAO,EAAE,OAAO,KACb,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,KACvD,sBAAsB,CAAC,QAAQ,CAAC;CACnC,CAAA;AAEF,sGAAsG;AACtG,eAAO,MAAM,YAAY,YACd,cAAc,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,KACxC,OAAO,CAAC,OAAO,oBAAoB,EAAE,aAAa,GAAG,SAAS,CAKhE,CAAA"}
package/build/protocol.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { assertContext } from '@owlmeans/context';
2
2
  import { EntrypointOutcome } from '@owlmeans/entrypoint';
3
+ import { HandlerMisconfiguredError } from './errors.js';
3
4
  const contextFor = (request, fallback) => {
4
5
  const original = request.original;
5
6
  if (original != null && typeof original === 'object' && '_ctx' in original) {
@@ -22,14 +23,58 @@ const bind = (protocol, execute) => ({
22
23
  return response.value;
23
24
  },
24
25
  });
26
+ /**
27
+ * `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
28
+ * its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
29
+ */
30
+ const isBoundHandler = (value) => typeof value === 'object' && value != null
31
+ && typeof value.bind === 'function'
32
+ && value.protocol != null;
33
+ const warnedAliases = new Set();
34
+ /**
35
+ * What `body`/`params`/`request` do with something that is not the plain callback they declare —
36
+ * the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
37
+ * second argument really is a callback and the caller should wrap it as usual.
38
+ *
39
+ * A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
40
+ * already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
41
+ * again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
42
+ * behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
43
+ * this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
44
+ * neither a function nor a bound handler at all — answers every request on this route with
45
+ * {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
46
+ * out of the request pipeline's own `try`, where the type name and the alias it happened on are
47
+ * both lost.
48
+ */
49
+ const toleratedHandler = (protocol, handler) => {
50
+ if (typeof handler === 'function')
51
+ return null;
52
+ if (isBoundHandler(handler)) {
53
+ if (handler.protocol.alias === protocol.alias) {
54
+ if (!warnedAliases.has(protocol.alias)) {
55
+ warnedAliases.add(protocol.alias);
56
+ console.warn(`[server-api] ${protocol.alias}: handler is already bound — pass it to bind() directly; `
57
+ + 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
58
+ + 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.');
59
+ }
60
+ return handler;
61
+ }
62
+ return bind(protocol, () => {
63
+ throw new HandlerMisconfiguredError(`${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`);
64
+ });
65
+ }
66
+ return bind(protocol, () => {
67
+ throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`);
68
+ });
69
+ };
25
70
  /**
26
71
  * Make server handlers that infer request sections and response values from their protocol.
27
72
  * The protocol is passed once; application callbacks never name request or reply generics.
28
73
  */
29
74
  export const handlers = () => ({
30
- body: (protocol, handler) => bind(protocol, (request, context) => handler(request.body, context, request)),
31
- params: (protocol, handler) => bind(protocol, (request, context) => handler(request.params, context, request)),
32
- request: (protocol, handler) => bind(protocol, handler),
75
+ body: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, (request, context) => handler(request.body, context, request)),
76
+ params: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, (request, context) => handler(request.params, context, request)),
77
+ request: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, handler),
33
78
  });
34
79
  /** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
35
80
  export const uploadedFile = async (request) => {
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAkBxD,MAAM,UAAU,GAAG,CACjB,OAAwB,EACxB,QAAiB,EACR,EAAE;IACX,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAA;IACjC,IAAI,QAAQ,IAAI,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,MAAM,IAAI,QAAQ,EAAE,CAAC;QAC3E,OAAQ,QAAoC,CAAC,IAAI,IAAI,QAAQ,CAAA;IAC/D,CAAC;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA;AAED,MAAM,IAAI,GAAG,CACX,QAAkB,EAClB,OAAgI,EAC9F,EAAE,CAAC,CAAC;IACtC,QAAQ;IACR,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE;QACvC,MAAM,QAAQ,GAAG,aAAa,CAAuB,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAA;QAElF,IAAI,CAAC;YACH,MAAM,YAAY,GAAI,OAA8E,CAAA;YACpG,MAAM,KAAK,GAAG,MAAM,OAAO,CACzB,YAAY,EACZ,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAC9B,CAAA;YACD,QAAQ,CAAC,OAAO,CAAC,KAA6B,EAAE,iBAAiB,CAAC,EAAE,CAAC,CAAA;QACvE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,CAAC,MAAM,CAAC,KAAc,CAAC,CAAA;QACjC,CAAC;QAED,OAAO,QAAQ,CAAC,KAAK,CAAA;IACvB,CAAC;CACF,CAAC,CAAA;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,GAA8C,EAAE,CAAC,CAAC;IACxE,IAAI,EAAE,CACJ,QAAkB,EAClB,OAKS,EACyB,EAAE,CAAC,IAAI,CAAoB,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,CAC3F,OAEwD,CAAC,OAAO,CAAC,IAAwB,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAEhH,MAAM,EAAE,CACN,QAAkB,EAClB,OAKS,EACyB,EAAE,CAAC,IAAI,CAAoB,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,CAC3F,OAEwD,CAAC,OAAO,CAAC,MAA4B,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAEpH,OAAO,EAAE,CACP,QAAkB,EAClB,OAGwD,EACtB,EAAE,CAAC,IAAI,CAAoB,QAAQ,EAAE,OAAO,CAAC;CAClF,CAAC,CAAA;AAEF,sGAAsG;AACtG,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,EAC/B,OAAyC,EACwB,EAAE;IACnE,MAAM,QAAQ,GAAI,OAA2B,CAAC,QAAQ,CAAA;IACtD,IAAI,QAAQ,IAAI,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,IAAI,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAA;IAE/F,OAAQ,QAA6C,CAAC,IAAI,EAAE,CAAA;AAC9D,CAAC,CAAA"}
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.24",
3
+ "version": "0.1.18-rc.26",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -b",
8
8
  "dev": "sleep 264 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
- "watch": "tsc -b -w --preserveWatchOutput --pretty"
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
10
11
  },
11
12
  "main": "build/index.js",
12
13
  "module": "build/index.js",
@@ -32,15 +33,15 @@
32
33
  "@fastify/helmet": "^13.1.0",
33
34
  "@fastify/middie": "^9.0.2",
34
35
  "@fastify/multipart": "^9.0.1",
35
- "@owlmeans/api": "^0.1.18-rc.20",
36
- "@owlmeans/auth": "^0.1.18-rc.16",
37
- "@owlmeans/auth-common": "^0.1.18-rc.20",
38
- "@owlmeans/context": "^0.1.18-rc.15",
39
- "@owlmeans/error": "^0.1.18-rc.15",
40
- "@owlmeans/entrypoint": "^0.1.18-rc.18",
41
- "@owlmeans/route": "^0.1.18-rc.16",
42
- "@owlmeans/server-context": "^0.1.18-rc.19",
43
- "@owlmeans/server-entrypoint": "^0.1.18-rc.18",
36
+ "@owlmeans/api": "^0.1.18-rc.22",
37
+ "@owlmeans/auth": "^0.1.18-rc.18",
38
+ "@owlmeans/auth-common": "^0.1.18-rc.22",
39
+ "@owlmeans/context": "^0.1.18-rc.17",
40
+ "@owlmeans/error": "^0.1.18-rc.17",
41
+ "@owlmeans/entrypoint": "^0.1.18-rc.20",
42
+ "@owlmeans/route": "^0.1.18-rc.18",
43
+ "@owlmeans/server-context": "^0.1.18-rc.21",
44
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.20",
44
45
  "ajv-errors": "^3.0.0",
45
46
  "fastify": "^5.0.0",
46
47
  "fastify-raw-body": "^5.0.0",
@@ -52,6 +53,7 @@
52
53
  },
53
54
  "devDependencies": {
54
55
  "@owlmeans/dep-config": "workspace:*",
56
+ "@types/bun": "^1.4.0",
55
57
  "@types/node": "^26.1.0",
56
58
  "nodemon": "^3.1.14",
57
59
  "typescript": "^7.0.2"
package/src/errors.ts CHANGED
@@ -29,7 +29,25 @@ export class NoFileError extends ApiError {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
34
+ * and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
35
+ * from inside the bound handler's own request path, so the module still loads and every OTHER
36
+ * route on it still answers; only the misconfigured one reports why, as a 500 rather than the
37
+ * opaque `TypeError: handler is not a function` a double wrap used to fail with. See
38
+ * `handlers()` in `protocol.ts` for what does and does not reach here.
39
+ */
40
+ export class HandlerMisconfiguredError extends ApiError {
41
+ public static override typeName = 'HandlerMisconfiguredError'
42
+
43
+ constructor(message: string = 'error') {
44
+ super(`handler:misconfigured:${message}`)
45
+ this.type = HandlerMisconfiguredError.typeName
46
+ }
47
+ }
48
+
32
49
  ResilientError.registerErrorClass(AuthFailedError)
33
50
  ResilientError.registerErrorClass(AccessError)
34
51
  ResilientError.registerErrorClass(NoFileError)
52
+ ResilientError.registerErrorClass(HandlerMisconfiguredError)
35
53
 
package/src/protocol.ts CHANGED
@@ -6,8 +6,29 @@ import type {
6
6
  } from '@owlmeans/entrypoint'
7
7
  import type { AbstractRequest } from '@owlmeans/entrypoint'
8
8
  import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
9
+ import { HandlerMisconfiguredError } from './errors.js'
9
10
 
10
- type HandlerResponse<Response> = [Response] extends [undefined] ? void | undefined : Response
11
+ /**
12
+ * True for a response the declaration left with no compile-time contract: `typed<any>()` (an
13
+ * `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
14
+ * checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
15
+ * a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
16
+ * with no shape to validate a handler's return against — the same signal a zero-argument
17
+ * `contract()` produces for a genuinely response-less route. Neither means "this handler must
18
+ * return void": it means the declaration did not constrain the response, so the handler is free to
19
+ * return whatever its route actually sends.
20
+ */
21
+ type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false
22
+
23
+ /**
24
+ * The type a bound handler is allowed to return for a given protocol response.
25
+ *
26
+ * A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
27
+ * exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
28
+ * declaration made no promise about the response, so nothing here should force a handler bound to
29
+ * it into returning `void`.
30
+ */
31
+ type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response
11
32
  type MaybePromise<Value> = Value | Promise<Value>
12
33
  type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { body: OpenValue }
13
34
  ? Protocol : never
@@ -53,6 +74,66 @@ const bind = <Protocol extends EntrypointProtocolDeclaration, Context extends Ba
53
74
  },
54
75
  })
55
76
 
77
+ /** Structural, not `instanceof`: a `BoundEntrypointHandler` crosses no class boundary a runtime check could use. */
78
+ type AnyBoundHandler = BoundEntrypointHandler<EntrypointProtocolDeclaration>
79
+
80
+ /**
81
+ * `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
82
+ * its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
83
+ */
84
+ const isBoundHandler = (value: unknown): value is AnyBoundHandler =>
85
+ typeof value === 'object' && value != null
86
+ && typeof (value as { bind?: unknown }).bind === 'function'
87
+ && (value as { protocol?: unknown }).protocol != null
88
+
89
+ const warnedAliases = new Set<string>()
90
+
91
+ /**
92
+ * What `body`/`params`/`request` do with something that is not the plain callback they declare —
93
+ * the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
94
+ * second argument really is a callback and the caller should wrap it as usual.
95
+ *
96
+ * A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
97
+ * already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
98
+ * again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
99
+ * behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
100
+ * this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
101
+ * neither a function nor a bound handler at all — answers every request on this route with
102
+ * {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
103
+ * out of the request pipeline's own `try`, where the type name and the alias it happened on are
104
+ * both lost.
105
+ */
106
+ const toleratedHandler = <Protocol extends EntrypointProtocolDeclaration>(
107
+ protocol: Protocol, handler: unknown
108
+ ): BoundEntrypointHandler<Protocol> | null => {
109
+ if (typeof handler === 'function') return null
110
+
111
+ if (isBoundHandler(handler)) {
112
+ if (handler.protocol.alias === protocol.alias) {
113
+ if (!warnedAliases.has(protocol.alias)) {
114
+ warnedAliases.add(protocol.alias)
115
+ console.warn(
116
+ `[server-api] ${protocol.alias}: handler is already bound — pass it to bind() directly; `
117
+ + 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
118
+ + 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.'
119
+ )
120
+ }
121
+
122
+ return handler as unknown as BoundEntrypointHandler<Protocol>
123
+ }
124
+
125
+ return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
126
+ throw new HandlerMisconfiguredError(
127
+ `${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`
128
+ )
129
+ })
130
+ }
131
+
132
+ return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
133
+ throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`)
134
+ })
135
+ }
136
+
56
137
  /**
57
138
  * Make server handlers that infer request sections and response values from their protocol.
58
139
  * The protocol is passed once; application callbacks never name request or reply generics.
@@ -66,7 +147,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
66
147
  request: HandlerRequest<RequestOf<Protocol>>,
67
148
  ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
68
149
  : never,
69
- ): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, (request, context) =>
150
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
70
151
  (handler as (
71
152
  payload: BodyOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
72
153
  ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.body as BodyOf<Protocol>, context, request)),
@@ -79,7 +160,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
79
160
  request: HandlerRequest<RequestOf<Protocol>>,
80
161
  ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
81
162
  : never,
82
- ): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, (request, context) =>
163
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
83
164
  (handler as (
84
165
  payload: ParamsOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
85
166
  ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.params as ParamsOf<Protocol>, context, request)),
@@ -90,7 +171,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
90
171
  request: HandlerRequest<RequestOf<Protocol>>,
91
172
  context: Context,
92
173
  ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
93
- ): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, handler),
174
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, handler),
94
175
  })
95
176
 
96
177
  /** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
3
+ import { handlers } from '../src/protocol.js'
4
+ import { HandlerMisconfiguredError } from '../src/errors.js'
5
+
6
+ /**
7
+ * A protocol-bound implementation is run with the smallest binding context the transport needs —
8
+ * same pattern as `@owlmeans/server-auth-token`'s own `handlers.spec.ts`.
9
+ */
10
+ const invoke = async (handler: any, context: any, req: any = {}): Promise<any> => {
11
+ const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
12
+ await handler.bind({ ref: { ctx: context } })(req, res)
13
+ if (res.error != null) throw res.error
14
+
15
+ return res.value
16
+ }
17
+
18
+ const protocolFor = (alias: string): EntrypointProtocolDeclaration =>
19
+ ({ alias } as unknown as EntrypointProtocolDeclaration)
20
+
21
+ const context = { alias: 'test-context' } as any
22
+ const api = handlers<any>()
23
+
24
+ describe('@owlmeans/server-api — handlers() wrap-once tolerance', () => {
25
+ test('a plain function is wrapped once and called with the context', async () => {
26
+ const protocol = protocolFor('api.thing.plain')
27
+ const handler = api.request(protocol, async (_req: any, ctx: any) => ({ ok: true, ctx }))
28
+
29
+ const result = await invoke(handler, context, {})
30
+ expect(result).toEqual({ ok: true, ctx: context })
31
+ })
32
+
33
+ test('a handler already bound to the SAME protocol is returned unchanged by body/params/request and still answers', async () => {
34
+ for (const method of ['body', 'params', 'request'] as const) {
35
+ const protocol = protocolFor(`api.thing.${method}`)
36
+ const bound = api.request(protocol, async () => ({ from: method }))
37
+
38
+ const wrapped = (api as any)[method](protocol, bound)
39
+ expect(wrapped).toBe(bound)
40
+
41
+ const result = await invoke(wrapped, context, {})
42
+ expect(result).toEqual({ from: method })
43
+ }
44
+ })
45
+
46
+ test('a handler bound to a DIFFERENT protocol answers with HandlerMisconfiguredError', async () => {
47
+ const protocolA = protocolFor('api.thing.mismatch-a')
48
+ const protocolB = protocolFor('api.thing.mismatch-b')
49
+ const boundToA = api.request(protocolA, async () => ({ from: 'a' }))
50
+
51
+ const wrapped = api.request(protocolB, boundToA as any)
52
+ expect(wrapped).not.toBe(boundToA)
53
+ await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
54
+ })
55
+
56
+ test('a value that is neither a function nor a bound handler answers with HandlerMisconfiguredError', async () => {
57
+ const protocol = protocolFor('api.thing.not-a-function')
58
+ const wrapped = api.request(protocol, { not: 'a function' } as any)
59
+
60
+ await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
61
+ })
62
+
63
+ test('the double-wrap warning fires once per alias, not once per call', async () => {
64
+ const protocol = protocolFor('api.thing.warn-once')
65
+ const bound = api.request(protocol, async () => 'ok')
66
+
67
+ const warnings: unknown[][] = []
68
+ const original = console.warn
69
+ console.warn = (...args: unknown[]) => { warnings.push(args) }
70
+ try {
71
+ // Each call is a deliberate double wrap — the exact shape `tsc` rejects at the real call
72
+ // site (TS2345) — so it is exercised here through `any`, the same way `body`/`params`
73
+ // are reached dynamically above.
74
+ (api as any).request(protocol, bound);
75
+ (api as any).body(protocol, bound);
76
+ (api as any).params(protocol, bound)
77
+ } finally {
78
+ console.warn = original
79
+ }
80
+
81
+ expect(warnings.length).toBe(1)
82
+ })
83
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "exclude": [
11
11
  "./dist/**/*",
12
12
  "./build/**/*",
13
+ "./tests/**/*",
13
14
  "./*.ts"
14
15
  ]
15
16
  }