@owlmeans/server-api 0.1.18-rc.8 → 0.1.18

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.
Files changed (140) hide show
  1. package/README.md +203 -46
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-api/SKILL.md +142 -22
  4. package/build/consts.local.d.ts +2 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +2 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -0
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -0
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +3 -6
  13. package/build/helper.d.ts.map +1 -1
  14. package/build/helper.js +10 -1
  15. package/build/helper.js.map +1 -1
  16. package/build/hold.d.ts +35 -0
  17. package/build/hold.d.ts.map +1 -0
  18. package/build/hold.js +57 -0
  19. package/build/hold.js.map +1 -0
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +2 -0
  23. package/build/index.js.map +1 -1
  24. package/build/protocol.d.ts +18 -0
  25. package/build/protocol.d.ts.map +1 -0
  26. package/build/protocol.js +87 -0
  27. package/build/protocol.js.map +1 -0
  28. package/build/server.d.ts +2 -3
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +42 -34
  31. package/build/server.js.map +1 -1
  32. package/build/types.d.ts +31 -2
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.local.d.ts +38 -0
  35. package/build/types.local.d.ts.map +1 -0
  36. package/build/types.local.js +2 -0
  37. package/build/types.local.js.map +1 -0
  38. package/build/utils/consts.d.ts +2 -0
  39. package/build/utils/consts.d.ts.map +1 -0
  40. package/build/utils/consts.js +2 -0
  41. package/build/utils/consts.js.map +1 -0
  42. package/build/utils/consts.local.d.ts +5 -0
  43. package/build/utils/consts.local.d.ts.map +1 -0
  44. package/build/utils/consts.local.js +5 -0
  45. package/build/utils/consts.local.js.map +1 -0
  46. package/build/utils/context/types.d.ts +13 -0
  47. package/build/utils/context/types.d.ts.map +1 -0
  48. package/build/utils/context/types.js +2 -0
  49. package/build/utils/context/types.js.map +1 -0
  50. package/build/utils/context.d.ts +3 -3
  51. package/build/utils/context.d.ts.map +1 -1
  52. package/build/utils/context.js +5 -2
  53. package/build/utils/context.js.map +1 -1
  54. package/build/utils/error/types.d.ts +52 -0
  55. package/build/utils/error/types.d.ts.map +1 -0
  56. package/build/utils/error/types.js +2 -0
  57. package/build/utils/error/types.js.map +1 -0
  58. package/build/utils/error.d.ts +5 -15
  59. package/build/utils/error.d.ts.map +1 -1
  60. package/build/utils/error.js +134 -29
  61. package/build/utils/error.js.map +1 -1
  62. package/build/utils/guards.d.ts +1 -5
  63. package/build/utils/guards.d.ts.map +1 -1
  64. package/build/utils/guards.js +15 -19
  65. package/build/utils/guards.js.map +1 -1
  66. package/build/utils/index.d.ts +6 -1
  67. package/build/utils/index.d.ts.map +1 -1
  68. package/build/utils/index.js +2 -1
  69. package/build/utils/index.js.map +1 -1
  70. package/build/utils/log/consts.d.ts +14 -0
  71. package/build/utils/log/consts.d.ts.map +1 -0
  72. package/build/utils/log/consts.js +17 -0
  73. package/build/utils/log/consts.js.map +1 -0
  74. package/build/utils/log/types.d.ts +42 -0
  75. package/build/utils/log/types.d.ts.map +1 -0
  76. package/build/utils/log/types.js +2 -0
  77. package/build/utils/log/types.js.map +1 -0
  78. package/build/utils/log.d.ts +4 -0
  79. package/build/utils/log.d.ts.map +1 -0
  80. package/build/utils/log.js +170 -0
  81. package/build/utils/log.js.map +1 -0
  82. package/build/utils/payload/types.d.ts +18 -0
  83. package/build/utils/payload/types.d.ts.map +1 -0
  84. package/build/utils/payload/types.js +2 -0
  85. package/build/utils/payload/types.js.map +1 -0
  86. package/build/utils/payload.d.ts +3 -15
  87. package/build/utils/payload.d.ts.map +1 -1
  88. package/build/utils/payload.js +41 -57
  89. package/build/utils/payload.js.map +1 -1
  90. package/build/utils/schema.d.ts +7 -0
  91. package/build/utils/schema.d.ts.map +1 -0
  92. package/build/utils/schema.js +20 -0
  93. package/build/utils/schema.js.map +1 -0
  94. package/build/utils/server.d.ts +3 -6
  95. package/build/utils/server.d.ts.map +1 -1
  96. package/build/utils/server.js +12 -13
  97. package/build/utils/server.js.map +1 -1
  98. package/build/utils/types.d.ts +18 -0
  99. package/build/utils/types.d.ts.map +1 -0
  100. package/build/utils/types.js +2 -0
  101. package/build/utils/types.js.map +1 -0
  102. package/build/utils/types.local.d.ts +9 -0
  103. package/build/utils/types.local.d.ts.map +1 -0
  104. package/build/utils/types.local.js +2 -0
  105. package/build/utils/types.local.js.map +1 -0
  106. package/package.json +23 -19
  107. package/src/consts.local.ts +1 -0
  108. package/src/errors.ts +18 -0
  109. package/src/helper.ts +13 -8
  110. package/src/hold.ts +66 -0
  111. package/src/index.ts +2 -0
  112. package/src/protocol.ts +149 -0
  113. package/src/server.ts +72 -69
  114. package/src/types.local.ts +44 -0
  115. package/src/types.ts +38 -4
  116. package/src/utils/consts.local.ts +7 -0
  117. package/src/utils/consts.ts +1 -0
  118. package/src/utils/context/types.ts +13 -0
  119. package/src/utils/context.ts +9 -4
  120. package/src/utils/error/types.ts +52 -0
  121. package/src/utils/error.ts +156 -29
  122. package/src/utils/guards.ts +16 -26
  123. package/src/utils/index.ts +6 -1
  124. package/src/utils/log/consts.ts +18 -0
  125. package/src/utils/log/types.ts +43 -0
  126. package/src/utils/log.ts +193 -0
  127. package/src/utils/payload/types.ts +18 -0
  128. package/src/utils/payload.ts +42 -60
  129. package/src/utils/schema.ts +23 -0
  130. package/src/utils/server.ts +16 -21
  131. package/src/utils/types.local.ts +12 -0
  132. package/src/utils/types.ts +18 -0
  133. package/tests/error-status.spec.ts +349 -0
  134. package/tests/handlers.spec.ts +83 -0
  135. package/tests/json-body.spec.ts +72 -0
  136. package/tests/log-adapter.spec.ts +62 -0
  137. package/tests/log-redaction.spec.ts +181 -0
  138. package/tests/tsconfig.json +12 -0
  139. package/tsconfig.json +1 -0
  140. package/build/.gitkeep +0 -0
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.8",
3
+ "version": "0.1.18",
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",
@@ -29,30 +30,33 @@
29
30
  },
30
31
  "dependencies": {
31
32
  "@fastify/cors": "^11.3.0",
32
- "@fastify/helmet": "^13.1.0",
33
- "@fastify/middie": "^9.0.2",
34
- "@fastify/multipart": "^9.0.1",
35
- "@owlmeans/api": "^0.1.18-rc.6",
36
- "@owlmeans/auth": "^0.1.18-rc.6",
37
- "@owlmeans/auth-common": "^0.1.18-rc.6",
38
- "@owlmeans/context": "^0.1.18-rc.6",
39
- "@owlmeans/error": "^0.1.18-rc.6",
40
- "@owlmeans/entrypoint": "^0.1.18-rc.6",
41
- "@owlmeans/route": "^0.1.18-rc.6",
42
- "@owlmeans/server-context": "^0.1.18-rc.6",
43
- "@owlmeans/server-entrypoint": "^0.1.18-rc.6",
33
+ "@fastify/helmet": "^13.1.1",
34
+ "@fastify/middie": "^9.3.4",
35
+ "@fastify/multipart": "^10.1.2",
36
+ "@owlmeans/api": "^0.1.18",
37
+ "@owlmeans/auth": "^0.1.18",
38
+ "@owlmeans/auth-common": "^0.1.18",
39
+ "@owlmeans/context": "^0.1.18",
40
+ "@owlmeans/entrypoint": "^0.1.18",
41
+ "@owlmeans/error": "^0.1.18",
42
+ "@owlmeans/log": "^0.1.18",
43
+ "@owlmeans/route": "^0.1.18",
44
+ "@owlmeans/server-context": "^0.1.18",
45
+ "@owlmeans/server-entrypoint": "^0.1.18",
44
46
  "ajv-errors": "^3.0.0",
45
- "fastify": "^5.0.0",
46
- "fastify-raw-body": "^5.0.0",
47
- "pino-pretty": "^13.0.0"
47
+ "fastify": "^5.12.5",
48
+ "fastify-raw-body": "^6.0.1",
49
+ "pino-pretty": "^13.2.0"
48
50
  },
49
51
  "peerDependencies": {
50
52
  "ajv": "*",
51
53
  "ajv-formats": "*"
52
54
  },
53
55
  "devDependencies": {
54
- "@owlmeans/dep-config": "workspace:*",
55
- "@types/node": "^26.1.0",
56
+ "@owlmeans/dep-config": "^0.1.18",
57
+ "@owlmeans/resource": "^0.1.18",
58
+ "@types/bun": "^1.4.2",
59
+ "@types/node": "^26.6.4",
56
60
  "nodemon": "^3.1.14",
57
61
  "typescript": "^7.0.2"
58
62
  },
@@ -0,0 +1 @@
1
+ export const bodyLimit = 1024 * 1024 * 20
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/helper.ts CHANGED
@@ -1,15 +1,21 @@
1
1
 
2
- import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
- import { assertContext } from '@owlmeans/context'
4
- import { EntrypointOutcome } from '@owlmeans/entrypoint'
5
- import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
2
+ import { type BasicConfig, type BasicContext, assertContext } from '@owlmeans/context'
3
+ import { EntrypointOutcome, type AbstractRequest, type AbstractResponse } from '@owlmeans/entrypoint'
6
4
  import type { RefedEntrypointHandler } from '@owlmeans/server-entrypoint'
7
- import type { Config, Context } from './types.js'
5
+ import type { Config, Context, UploadedFile } from './types.js'
8
6
  import type { FastifyRequest } from 'fastify'
9
- import type { MultipartFile } from '@fastify/multipart'
10
7
 
8
+ /**
9
+ * The context a handler runs against.
10
+ *
11
+ * The HTTP boundary hangs the request-scoped context on the raw Fastify request, so a handler
12
+ * reached that way gets it from there. A request that did not come from Fastify has no `original`
13
+ * at all — a queued call is rebuilt from an envelope, and a socket frame carries its own — so this
14
+ * has to fall back rather than dereference: these handlers are the same functions on every
15
+ * transport, and only the way they were reached differs.
16
+ */
11
17
  const _castContextFromOriginal = <C extends BasicConfig, T extends BasicContext<C> = BasicContext<C>>(req: AbstractRequest, def: T): T => {
12
- return req.original._ctx ?? def
18
+ return req.original?._ctx ?? def
13
19
  }
14
20
 
15
21
  export const handleBody: <T>(
@@ -86,4 +92,3 @@ export const extractUploadedFile = async <T extends {} = {}>(req: AbstractReques
86
92
  return request.file()
87
93
  }
88
94
 
89
- export interface UploadedFile extends MultipartFile { }
package/src/hold.ts ADDED
@@ -0,0 +1,66 @@
1
+ import Fastify from 'fastify'
2
+ import { logger } from '@owlmeans/log'
3
+ import type { ServerConfig } from '@owlmeans/server-context'
4
+ import { CLOSED_HOST, OPENED_HOST, PORT } from './consts.js'
5
+ import type { ApiPortHold, ApiPortHoldOptions } from './types.js'
6
+
7
+ /**
8
+ * Own the app's port while the context initializes, and answer for the app until `listen()`
9
+ * takes over.
10
+ *
11
+ * The API server itself cannot do this: it builds its route table from the entrypoints the
12
+ * context knows about, so it can only bind AFTER `init()` resolves — and Fastify refuses new
13
+ * routes once it is listening. Without a hold, the whole boot window, and every way a boot can
14
+ * fail, leaves nothing on the port; the edge then answers a bare upstream connect error that
15
+ * names neither the app nor the reason, which is indistinguishable from a crashed pod or an app
16
+ * that was never installed.
17
+ *
18
+ * So the boot sequence an app wants is:
19
+ *
20
+ * const hold = await holdApiPort(cfg, { okPath, payload }) // bind FIRST
21
+ * try { ...initialize... } catch { record the failure; return } // hold keeps answering
22
+ * await hold.release()
23
+ * await context.getApiServer().listen()
24
+ *
25
+ * Everything that is not the health path answers **503, not 404**: the routes the app really
26
+ * serves do not exist yet, and "no such route" would be a lie that outlives the boot.
27
+ *
28
+ * The port and host are resolved exactly as `listen()` resolves them — from the service's own
29
+ * declaration in `cfg.services[cfg.service]` — so the hold and the server can never disagree
30
+ * about which socket they are trading.
31
+ *
32
+ * A bind failure here (EADDRINUSE above all) throws to the caller and must be treated as fatal:
33
+ * a process that continues past it ends the boot with nothing listening and nothing holding the
34
+ * event loop, i.e. a clean exit 0 while a predecessor keeps serving stale code. Name the error
35
+ * and exit non-zero — that is what makes a supervisor reclaim the port instead of respawning
36
+ * into it.
37
+ */
38
+ export const holdApiPort = async (
39
+ cfg: ServerConfig, opts: ApiPortHoldOptions = {}
40
+ ): Promise<ApiPortHold> => {
41
+ const config = cfg.services[cfg.service]
42
+ const port = config?.internalPort ?? config?.port ?? PORT
43
+ const host = config?.opened === true ? OPENED_HOST : CLOSED_HOST
44
+
45
+ const payload = opts.payload ?? (() => ({ status: 'OK' }))
46
+
47
+ // Quiet and disposable: no logger, no plugins, and connections are force-closed on release so
48
+ // the handover to the real server is bounded rather than waiting out a keep-alive.
49
+ const server = Fastify({ logger: false, forceCloseConnections: true })
50
+
51
+ if (opts.okPath != null) {
52
+ server.all(opts.okPath, async (_request, reply) => {
53
+ return await reply.code(200).send(payload())
54
+ })
55
+ }
56
+ server.setNotFoundHandler(async (_request, reply) => {
57
+ return await reply.code(503).send(payload())
58
+ })
59
+
60
+ await server.listen({ port, host })
61
+ logger('server').info(`api-server: holding ${host}:${port} for the boot${opts.okPath != null ? ` (${opts.okPath})` : ''}`, undefined, { event: 'server.hold' })
62
+
63
+ return {
64
+ release: async () => { await server.close() },
65
+ }
66
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export * from './server.js'
4
4
  export * from './consts.js'
5
5
  export * from './errors.js'
6
6
  export * from './helper.js'
7
+ export * from './hold.js'
8
+ export * from './protocol.js'
@@ -0,0 +1,149 @@
1
+ import { assertContext, type BasicConfig, type BasicContext } from '@owlmeans/context'
2
+ import { EntrypointOutcome, type BodyOf, type EntrypointProtocolDeclaration, type HandlerRequest, type OpenRequest, type ParamsOf, type RequestOf, type ResponseOf, type AbstractRequest } from '@owlmeans/entrypoint'
3
+ import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
4
+ import { HandlerMisconfiguredError } from './errors.js'
5
+ import { logger } from '@owlmeans/log'
6
+ import type { AnyBoundHandler, BodyProtocol, ContextCarrier, HandlerResponse, MaybePromise, ParamsProtocol } from './types.local.js'
7
+
8
+
9
+ const contextFor = <Context extends BasicContext<BasicConfig>>(
10
+ request: AbstractRequest,
11
+ fallback: Context,
12
+ ): Context => {
13
+ const original = request.original
14
+ if (original != null && typeof original === 'object' && '_ctx' in original) {
15
+ return (original as ContextCarrier<Context>)._ctx ?? fallback
16
+ }
17
+
18
+ return fallback
19
+ }
20
+
21
+ const bind = <Protocol extends EntrypointProtocolDeclaration, Context extends BasicContext<BasicConfig>>(
22
+ protocol: Protocol,
23
+ execute: (request: HandlerRequest<RequestOf<Protocol>>, context: Context) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
24
+ ): BoundEntrypointHandler<Protocol> => ({
25
+ protocol,
26
+ bind: ref => async (request, response) => {
27
+ const fallback = assertContext<BasicConfig, Context>(ref.ref?.ctx, protocol.alias)
28
+
29
+ try {
30
+ const typedRequest = (request as HandlerRequest<OpenRequest>) as HandlerRequest<RequestOf<Protocol>>
31
+ const value = await execute(
32
+ typedRequest,
33
+ contextFor(request, fallback),
34
+ )
35
+ response.resolve(value as ResponseOf<Protocol>, EntrypointOutcome.Ok)
36
+ } catch (error) {
37
+ response.reject(error as Error)
38
+ }
39
+
40
+ return response.value
41
+ },
42
+ })
43
+
44
+ /**
45
+ * `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
46
+ * its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
47
+ */
48
+ const isBoundHandler = (value: unknown): value is AnyBoundHandler =>
49
+ typeof value === 'object' && value != null
50
+ && typeof (value as { bind?: unknown }).bind === 'function'
51
+ && (value as { protocol?: unknown }).protocol != null
52
+
53
+ const warnedAliases = new Set<string>()
54
+
55
+ /**
56
+ * What `body`/`params`/`request` do with something that is not the plain callback they declare —
57
+ * the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
58
+ * second argument really is a callback and the caller should wrap it as usual.
59
+ *
60
+ * A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
61
+ * already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
62
+ * again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
63
+ * behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
64
+ * this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
65
+ * neither a function nor a bound handler at all — answers every request on this route with
66
+ * {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
67
+ * out of the request pipeline's own `try`, where the type name and the alias it happened on are
68
+ * both lost.
69
+ */
70
+ const toleratedHandler = <Protocol extends EntrypointProtocolDeclaration>(
71
+ protocol: Protocol, handler: unknown
72
+ ): BoundEntrypointHandler<Protocol> | null => {
73
+ if (typeof handler === 'function') return null
74
+
75
+ if (isBoundHandler(handler)) {
76
+ if (handler.protocol.alias === protocol.alias) {
77
+ if (!warnedAliases.has(protocol.alias)) {
78
+ warnedAliases.add(protocol.alias)
79
+ logger('server-api').warn(
80
+ `${protocol.alias}: handler is already bound — pass it to bind() directly; `
81
+ + 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
82
+ + 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.'
83
+ )
84
+ }
85
+
86
+ return handler as unknown as BoundEntrypointHandler<Protocol>
87
+ }
88
+
89
+ return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
90
+ throw new HandlerMisconfiguredError(
91
+ `${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`
92
+ )
93
+ })
94
+ }
95
+
96
+ return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
97
+ throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`)
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Make server handlers that infer request sections and response values from their protocol.
103
+ * The protocol is passed once; application callbacks never name request or reply generics.
104
+ */
105
+ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
106
+ body: <Protocol extends EntrypointProtocolDeclaration>(
107
+ protocol: Protocol,
108
+ handler: Protocol extends BodyProtocol<Protocol> ? (
109
+ payload: BodyOf<Protocol>,
110
+ context: Context,
111
+ request: HandlerRequest<RequestOf<Protocol>>,
112
+ ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
113
+ : never,
114
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
115
+ (handler as (
116
+ payload: BodyOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
117
+ ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.body as BodyOf<Protocol>, context, request)),
118
+
119
+ params: <Protocol extends EntrypointProtocolDeclaration>(
120
+ protocol: Protocol,
121
+ handler: Protocol extends ParamsProtocol<Protocol> ? (
122
+ payload: ParamsOf<Protocol>,
123
+ context: Context,
124
+ request: HandlerRequest<RequestOf<Protocol>>,
125
+ ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
126
+ : never,
127
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
128
+ (handler as (
129
+ payload: ParamsOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
130
+ ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.params as ParamsOf<Protocol>, context, request)),
131
+
132
+ request: <Protocol extends EntrypointProtocolDeclaration>(
133
+ protocol: Protocol,
134
+ handler: (
135
+ request: HandlerRequest<RequestOf<Protocol>>,
136
+ context: Context,
137
+ ) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
138
+ ): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, handler),
139
+ })
140
+
141
+ /** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
142
+ export const uploadedFile = async (
143
+ request: HandlerRequest<{ body: object }>,
144
+ ): Promise<import('@fastify/multipart').MultipartFile | undefined> => {
145
+ const original = (request as AbstractRequest).original
146
+ if (original == null || typeof original !== 'object' || !('file' in original)) return undefined
147
+
148
+ return (original as import('fastify').FastifyRequest).file()
149
+ }
package/src/server.ts CHANGED
@@ -1,13 +1,16 @@
1
- import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
1
+ import type { ServerContext } from '@owlmeans/server-context'
2
2
  import type { ApiServer, ApiServerAppend } from './types.js'
3
- import { Layer, assertContext, createService } from '@owlmeans/context'
4
- import { canServeModule, executeResponse, provideRequest } from './utils/index.js'
3
+ import { assertContext, createService } from '@owlmeans/context'
4
+ import { canServeModule, payloadHelper, createServerHandler, fixFormatDates } from './utils/index.js'
5
5
  import { DEFAULT_ALIAS, CLOSED_HOST, PORT, OPENED_HOST } from './consts.js'
6
6
  import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
7
7
  import { RouteMethod } from '@owlmeans/route'
8
- import { createServerHandler, fixFormatDates } from './utils/index.js'
9
8
  import { provideResponse } from '@owlmeans/entrypoint'
10
9
  import { TOKEN_UPDATE } from '@owlmeans/auth-common'
10
+ import { INCIDENT_ID_HEADER } from './utils/consts.js'
11
+ import { DENIAL_KIND_HEADER } from '@owlmeans/api'
12
+ import { logger } from '@owlmeans/log'
13
+ import { fastifyLogUtils } from './utils/log.js'
11
14
 
12
15
  import Fastify from 'fastify'
13
16
  import type { FastifyRequest } from 'fastify'
@@ -20,6 +23,8 @@ import Multipart from '@fastify/multipart'
20
23
  import formatsPlugin from 'ajv-formats'
21
24
  import Ajv from 'ajv'
22
25
  import ajvErrors from "ajv-errors"
26
+ import { bodyLimit } from './consts.local.js'
27
+ import type { Config, Context } from './types.local.js'
23
28
 
24
29
 
25
30
  const ajv = new Ajv({
@@ -32,32 +37,29 @@ const ajv = new Ajv({
32
37
  formatsPlugin(ajv)
33
38
  ajvErrors(ajv, { singleError: true })
34
39
 
35
- type Config = ServerConfig
36
- type Context = ServerContext<Config>
37
40
 
38
- const bodyLimit = 1024 * 1024 * 20
41
+ const http = logger('http')
42
+
43
+ /**
44
+ * Fastify logging through `@owlmeans/log`, never with what a caller sent. The adapter writes every
45
+ * record's data through `safeData` (no body, raw body, multipart bytes, headers, cookies or query);
46
+ * the log controller drops Fastify's per-request pair — a request is a debug record written once
47
+ * from `onResponse` (below) — and logs a request Fastify itself refuses (a schema-invalid body, an
48
+ * unparsable payload, a size limit) like `handleError` logs a failed handler: method, path, status,
49
+ * error code and message.
50
+ */
51
+ const createFastify = () => Fastify({
52
+ loggerInstance: fastifyLogUtils.logger(http),
53
+ logController: fastifyLogUtils.controller(),
54
+ bodyLimit,
55
+ })
39
56
 
40
57
  export const createApiServer = (alias: string): ApiServer => {
41
58
  const location = `service:${alias}`
42
59
  const _assertContext = (context: Context | undefined): Context => assertContext<Config, Context>(context, location)
43
60
 
44
61
  const service = createService<ApiServer>(alias, {
45
- server: Fastify({
46
- logger: true,
47
- bodyLimit,
48
- /*{
49
- transport: {
50
- target: 'pino-pretty',
51
- options: {
52
- singleLine: true,
53
- translateTime: 'HH:MM:ss Z',
54
- ignore: 'pid,hostname',
55
- },
56
- },
57
- }*/
58
- }),
59
-
60
- layers: [Layer.System],
62
+ server: createFastify(),
61
63
 
62
64
  listen: async () => {
63
65
  const context = _assertContext(service.ctx as Context)
@@ -67,7 +69,7 @@ export const createApiServer = (alias: string): ApiServer => {
67
69
  const port = config?.internalPort ?? config?.port ?? PORT
68
70
  const host = config.opened === true ? OPENED_HOST : CLOSED_HOST
69
71
  await service.server.listen({ port, host })
70
- console.info(`${location}: server listening on ${host}${port != null ? `:${port}` : ''}`)
72
+ logger('server').info(`${location}: server listening on ${host}${port != null ? `:${port}` : ''}`, undefined, { event: 'server.listening' })
71
73
 
72
74
  process.on('SIGTERM', () => {
73
75
  service.server.close().then(() => {
@@ -78,7 +80,7 @@ export const createApiServer = (alias: string): ApiServer => {
78
80
  }, service => async () => {
79
81
  if (service.server.server.listening) {
80
82
  await service.server.close()
81
- service.server = Fastify({ logger: true, bodyLimit })
83
+ service.server = createFastify()
82
84
  }
83
85
 
84
86
  const context = _assertContext(service.ctx as Context)
@@ -86,6 +88,15 @@ export const createApiServer = (alias: string): ApiServer => {
86
88
  const server = service.server
87
89
  // @TODO We should ensure some way that Resilient Error is thrown and go to the flow
88
90
  server.setValidatorCompiler(opts => ajv.compile(opts.schema))
91
+ server.addHook('onResponse', (request, reply, done) => {
92
+ if (http.enabled('debug')) {
93
+ // The path only: a query string can carry a token.
94
+ http.debug('request', {
95
+ ...fastifyLogUtils.requestOf(request), status: reply.statusCode, ms: Math.round(reply.elapsedTime),
96
+ })
97
+ }
98
+ done()
99
+ })
89
100
  // @TODO It's quite unsafe and should be properly configured
90
101
  await server.register(cors, {
91
102
  origin: '*',
@@ -101,7 +112,7 @@ export const createApiServer = (alias: string): ApiServer => {
101
112
  ...Object.values(RouteMethod).map(method => method.toUpperCase()),
102
113
  'HEAD', 'OPTIONS'
103
114
  ],
104
- exposedHeaders: [TOKEN_UPDATE]
115
+ exposedHeaders: [TOKEN_UPDATE, INCIDENT_ID_HEADER, DENIAL_KIND_HEADER]
105
116
  })
106
117
 
107
118
  await server.register(Multipart, {
@@ -133,59 +144,53 @@ export const createApiServer = (alias: string): ApiServer => {
133
144
  return context
134
145
  }
135
146
 
136
- // await module.route.resolve(context as any)
137
- await module.resolve()
138
- // Actually intermediate module can be created without handler by elevate function
139
- if (module.route.match(request) && module.handle != null) {
147
+ // An intermediate route can be created without a handler by the binding layer.
148
+ if (module.route.match(request, module.mount()) && module.handle != null) {
140
149
  const response = provideResponse(reply)
141
- const currentRequest = provideRequest(module.alias, request, true)
150
+ const currentRequest = payloadHelper.provideRequest(module.alias, request, true)
142
151
  currentRequest.original._ctx = context
143
152
  const result: Context = await module.handle(currentRequest, response)
144
153
  if (result != null) {
145
154
  context = result
146
155
  }
147
- responded = executeResponse(response, reply, true) || responded
156
+ responded = payloadHelper.executeResponse(response, reply, true) || responded
148
157
  }
149
158
  return context
150
159
  }, Promise.resolve(context))
151
160
  })
152
161
 
153
- await Promise.all(
154
- context.entrypoints<ServerEntrypoint<FastifyRequest>>()
155
- .filter(module => canServeModule(context, module) && !module.route.isIntermediate())
156
- .map(async module => {
157
- // await module.route.resolve(context as any)
158
- await module.resolve()
159
- const method = module.route.route.method ?? RouteMethod.GET
160
- if (module.handle == null) {
161
- return
162
- }
163
- server.route({
164
- url: module.getPath(), method,
165
- schema: {
166
- consumes: [
167
- 'application/json',
168
- 'application/x-www-form-urlencoded',
169
- 'multipart/form-data',
170
- ],
171
- querystring: fixFormatDates(module.filter?.query ?? {}),
172
- ...(
173
- [
174
- RouteMethod.POST,
175
- RouteMethod.PATCH,
176
- RouteMethod.PUT
177
- ].includes(method)
178
- ? { body: fixFormatDates(module.filter?.body ?? {}) }
179
- : {}
180
- ),
181
- params: fixFormatDates(module.filter?.params ?? {}),
182
- response: module.filter?.response,
183
- headers: fixFormatDates(module.filter?.headers ?? {})
184
- },
185
- handler: createServerHandler(module, location)
186
- })
162
+ context.entrypoints<ServerEntrypoint<FastifyRequest>>()
163
+ .filter(module => canServeModule(context, module) && !module.route.isIntermediate())
164
+ .forEach(module => {
165
+ const method = module.route.route.method ?? RouteMethod.GET
166
+ if (module.handle == null) {
167
+ return
168
+ }
169
+ server.route({
170
+ url: module.mount(), method,
171
+ schema: {
172
+ consumes: [
173
+ 'application/json',
174
+ 'application/x-www-form-urlencoded',
175
+ 'multipart/form-data',
176
+ ],
177
+ querystring: fixFormatDates(module.filter?.query ?? {}),
178
+ ...(
179
+ [
180
+ RouteMethod.POST,
181
+ RouteMethod.PATCH,
182
+ RouteMethod.PUT
183
+ ].includes(method)
184
+ ? { body: fixFormatDates(module.filter?.body ?? {}) }
185
+ : {}
186
+ ),
187
+ params: fixFormatDates(module.filter?.params ?? {}),
188
+ response: module.filter?.response,
189
+ headers: fixFormatDates(module.filter?.headers ?? {})
190
+ },
191
+ handler: createServerHandler(module, location)
187
192
  })
188
- )
193
+ })
189
194
 
190
195
  service.initialized = true
191
196
  })
@@ -207,5 +212,3 @@ export const appendApiServer = <C extends Config, T extends ServerContext<C>>(
207
212
 
208
213
  return context
209
214
  }
210
-
211
-
@@ -0,0 +1,44 @@
1
+ import type { EntrypointProtocolDeclaration, OpenValue, RequestOf } from '@owlmeans/entrypoint'
2
+ import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
3
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
4
+
5
+ /**
6
+ * True for a response the declaration left with no compile-time contract: `typed<any>()` (an
7
+ * `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
8
+ * checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
9
+ * a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
10
+ * with no shape to validate a handler's return against — the same signal a zero-argument
11
+ * `contract()` produces for a genuinely response-less route. Neither means "this handler must
12
+ * return void": it means the declaration did not constrain the response, so the handler is free to
13
+ * return whatever its route actually sends.
14
+ */
15
+ export type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false
16
+
17
+ /**
18
+ * The type a bound handler is allowed to return for a given protocol response.
19
+ *
20
+ * A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
21
+ * exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
22
+ * declaration made no promise about the response, so nothing here should force a handler bound to
23
+ * it into returning `void`.
24
+ */
25
+ export type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response
26
+
27
+ export type MaybePromise<Value> = Value | Promise<Value>
28
+
29
+ export type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { body: OpenValue }
30
+ ? Protocol : never
31
+
32
+ export type ParamsProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { params: object }
33
+ ? Protocol : never
34
+
35
+ export type Config = ServerConfig
36
+
37
+ export type Context = ServerContext<Config>
38
+
39
+ export interface ContextCarrier<Context> {
40
+ _ctx?: Context
41
+ }
42
+
43
+ /** Structural, not `instanceof`: a `BoundEntrypointHandler` crosses no class boundary a runtime check could use. */
44
+ export type AnyBoundHandler = BoundEntrypointHandler<EntrypointProtocolDeclaration>