@znight/http 0.1.2 → 0.1.4
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 +154 -35
- package/dist/compiler.d.mts +77 -2
- package/dist/compiler.mjs +259 -101
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.mjs +200 -23
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.mjs +174 -43
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +36 -4
- package/dist/index.mjs +1005 -257
- package/dist/index.mjs.map +1 -1
- package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
- package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.mjs +1 -1
- package/dist/utils-DIWdzVht.mjs +46 -0
- package/dist/utils-DIWdzVht.mjs.map +1 -0
- package/package.json +4 -4
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +265 -25
- package/src/adapters/hono.ts +225 -58
- package/src/adapters/node-io.ts +128 -58
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/analyze.ts +3 -3
- package/src/compiler/generate.ts +127 -22
- package/src/compiler/index.ts +13 -0
- package/src/compiler/route-decorators.ts +45 -2
- package/src/compiler/route-groups.ts +216 -0
- package/src/compiler/validate.ts +15 -126
- package/src/context.ts +148 -78
- package/src/decorators.ts +19 -3
- package/src/index.ts +2 -1
- package/src/pipeline.ts +936 -177
- package/src/plugin-routes.ts +19 -0
- package/src/plugin.ts +7 -3
- package/src/response.ts +6 -8
- package/src/serialize.ts +48 -21
- package/src/utils.ts +25 -2
- package/src/validate.ts +28 -12
- package/dist/utils-DCbu3a4w.mjs +0 -27
- package/dist/utils-DCbu3a4w.mjs.map +0 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
/** Internal helpers (not part of the public API). */
|
|
3
|
+
/**
|
|
4
|
+
* Whether `value` is a promise-like object (has a callable `then`). The guard keeps the thenable
|
|
5
|
+
* part of the value's type (`Promise<R>` of `R | Promise<R>`, even when `R` is `object` or `any`), so
|
|
6
|
+
* that generated code awaiting it gets `R`.
|
|
7
|
+
*/
|
|
8
|
+
function isThenable(value) {
|
|
9
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
|
|
10
|
+
}
|
|
11
|
+
/** Message of any thrown value. */
|
|
12
|
+
function errorMessage(error) {
|
|
13
|
+
if (error instanceof Error) return error.message;
|
|
14
|
+
if (typeof error === "string") return error;
|
|
15
|
+
try {
|
|
16
|
+
return String(error);
|
|
17
|
+
} catch {
|
|
18
|
+
return Object.prototype.toString.call(error);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */
|
|
22
|
+
function emptyRecord() {
|
|
23
|
+
return Object.create(null);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Constructor of {@link fastRecord}: its prototype is an empty, frozen object without prototype, so
|
|
27
|
+
* nothing is inherited (`__proto__`, `constructor`, `toString`... are plain own keys).
|
|
28
|
+
*/
|
|
29
|
+
const FastRecord = function FastRecord() {};
|
|
30
|
+
FastRecord.prototype = Object.freeze(Object.create(null));
|
|
31
|
+
/**
|
|
32
|
+
* A `Record` that inherits nothing, like {@link emptyRecord}, but that V8 keeps in fast mode: an
|
|
33
|
+
* `Object.create(null)` object is a dictionary, about twice as slow to fill and enumerate (response
|
|
34
|
+
* headers: built, completed and enumerated on every request). Its prototype is not `null` (an empty
|
|
35
|
+
* frozen object): use {@link emptyRecord} for records handed to user code (query, fields...).
|
|
36
|
+
*/
|
|
37
|
+
function fastRecord() {
|
|
38
|
+
return new FastRecord();
|
|
39
|
+
}
|
|
40
|
+
/** Whether the code runs on Bun. */
|
|
41
|
+
function isBun() {
|
|
42
|
+
return typeof globalThis.Bun !== "undefined";
|
|
43
|
+
}
|
|
44
|
+
export { isThenable as a, isBun as i, errorMessage as n, fastRecord as r, emptyRecord as t };
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=utils-DIWdzVht.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils-DIWdzVht.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/** Internal helpers (not part of the public API). */\n\n/**\n * Whether `value` is a promise-like object (has a callable `then`). The guard keeps the thenable\n * part of the value's type (`Promise<R>` of `R | Promise<R>`, even when `R` is `object` or `any`), so\n * that generated code awaiting it gets `R`.\n */\nexport function isThenable<T>(\n value: T,\n): value is unknown extends T ? T & PromiseLike<unknown> : Extract<T, PromiseLike<unknown>> {\n return (\n value !== null &&\n (typeof value === 'object' || typeof value === 'function') &&\n typeof (value as { then?: unknown }).then === 'function'\n )\n}\n\n/** Message of any thrown value. */\nexport function errorMessage(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n try {\n return String(error)\n } catch {\n return Object.prototype.toString.call(error)\n }\n}\n\n/** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */\nexport function emptyRecord<V>(): Record<string, V> {\n return Object.create(null) as Record<string, V>\n}\n\n/**\n * Constructor of {@link fastRecord}: its prototype is an empty, frozen object without prototype, so\n * nothing is inherited (`__proto__`, `constructor`, `toString`... are plain own keys).\n */\nconst FastRecord = function FastRecord() {} as unknown as new () => object\nFastRecord.prototype = Object.freeze(Object.create(null))\n\n/**\n * A `Record` that inherits nothing, like {@link emptyRecord}, but that V8 keeps in fast mode: an\n * `Object.create(null)` object is a dictionary, about twice as slow to fill and enumerate (response\n * headers: built, completed and enumerated on every request). Its prototype is not `null` (an empty\n * frozen object): use {@link emptyRecord} for records handed to user code (query, fields...).\n */\nexport function fastRecord<V>(): Record<string, V> {\n return new FastRecord() as Record<string, V>\n}\n\n/** Whether the code runs on Bun. */\nexport function isBun(): boolean {\n return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,WACd,OAC0F;CAC1F,OACE,UAAU,SACT,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;;AAGA,SAAgB,aAAa,OAAwB;CACnD,IAAI,iBAAiB,OAAO,OAAO,MAAM;CACzC,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,IAAI;EACF,OAAO,OAAO,KAAK;CACrB,QAAQ;EACN,OAAO,OAAO,UAAU,SAAS,KAAK,KAAK;CAC7C;AACF;;AAGA,SAAgB,cAAoC;CAClD,OAAO,OAAO,OAAO,IAAI;AAC3B;;;;;AAMA,MAAM,aAAa,SAAS,aAAa,CAAC;AAC1C,WAAW,YAAY,OAAO,OAAO,OAAO,OAAO,IAAI,CAAC;;;;;;;AAQxD,SAAgB,aAAmC;CACjD,OAAO,IAAI,WAAW;AACxB;;AAGA,SAAgB,QAAiB;CAC/B,OAAO,OAAQ,WAAiC,QAAQ;AAC1D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ugho STEPHAN",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"access": "public"
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@znight/core": "0.1.
|
|
82
|
+
"@znight/core": "0.1.4"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"@hono/node-server": "^2.1.5",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"fastify": "^5.12.7",
|
|
88
88
|
"hono": "^4.13.14",
|
|
89
89
|
"light-my-request": "^6.8.0",
|
|
90
|
-
"@znight/compiler": "^0.1.
|
|
90
|
+
"@znight/compiler": "^0.1.4"
|
|
91
91
|
},
|
|
92
92
|
"peerDependenciesMeta": {
|
|
93
93
|
"@hono/node-server": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"valibot": "1.5.0",
|
|
119
119
|
"zod": "4.6.7",
|
|
120
120
|
"light-my-request": "6.8.0",
|
|
121
|
-
"@znight/compiler": "0.1.
|
|
121
|
+
"@znight/compiler": "0.1.4"
|
|
122
122
|
},
|
|
123
123
|
"znight": {
|
|
124
124
|
"apiVersion": 1,
|
package/src/adapters/express.ts
CHANGED
|
@@ -133,6 +133,8 @@ class ExpressHttpAdapter implements ExpressAdapter {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
readonly #listener = (request: IncomingMessage, response: ServerResponse): void => {
|
|
136
|
+
// the listening server's connections (an injected request is ignored)
|
|
137
|
+
this.#tracker?.track(request, response)
|
|
136
138
|
this.app(request as Request, response as Response)
|
|
137
139
|
}
|
|
138
140
|
|
|
@@ -141,7 +143,8 @@ class ExpressHttpAdapter implements ExpressAdapter {
|
|
|
141
143
|
const server = this.#givenServer ?? createServer()
|
|
142
144
|
if (!server.listeners('request').includes(this.#listener)) server.on('request', this.#listener)
|
|
143
145
|
this.#server = server
|
|
144
|
-
|
|
146
|
+
// `#listener` counts the requests
|
|
147
|
+
this.#tracker ??= new ConnectionTracker(server, { requests: false })
|
|
145
148
|
this.#closing = undefined
|
|
146
149
|
await new Promise<void>((resolve, reject) => {
|
|
147
150
|
const onError = (error: Error): void => reject(error)
|
package/src/adapters/fastify.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* peer dependency `fastify`). Routing uses Fastify's router; body parsing, validation and
|
|
4
4
|
* serialisation stay Znight's, so responses are identical to the other adapters.
|
|
5
5
|
*/
|
|
6
|
+
|
|
7
|
+
import type { OutgoingHttpHeaders } from 'node:http'
|
|
6
8
|
import type {
|
|
7
9
|
AdapterCloseOptions,
|
|
8
10
|
AdapterHandler,
|
|
@@ -19,12 +21,15 @@ import type {
|
|
|
19
21
|
} from '../adapter.ts'
|
|
20
22
|
import type { HeaderValue } from '../exceptions.ts'
|
|
21
23
|
|
|
24
|
+
import { tracingChannel } from 'node:diagnostics_channel'
|
|
22
25
|
import { Readable } from 'node:stream'
|
|
23
26
|
import Fastify, {
|
|
24
27
|
type FastifyInstance,
|
|
25
28
|
type FastifyReply,
|
|
26
29
|
type FastifyRequest,
|
|
27
30
|
type FastifyServerOptions,
|
|
31
|
+
type HookHandlerDoneFunction,
|
|
32
|
+
type RouteOptions,
|
|
28
33
|
} from 'fastify'
|
|
29
34
|
|
|
30
35
|
import { toFastifyPath } from '../path.ts'
|
|
@@ -87,11 +92,70 @@ const MAX_PARAM_LENGTH = 8192
|
|
|
87
92
|
/** `content-type` headers hidden from Fastify (see `#register`), by request. */
|
|
88
93
|
const HIDDEN_CONTENT_TYPES = new WeakMap<object, string>()
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Fastify's tracing channel of route handlers: while it has subscribers (an APM), every request runs
|
|
97
|
+
* Fastify's whole lifecycle, so that the handler is traced.
|
|
98
|
+
*/
|
|
99
|
+
const HANDLER_CHANNEL = tracingChannel('fastify.request.handler')
|
|
100
|
+
|
|
101
|
+
/** The `config` key marking a Znight route that may be served from the `onRequest` hook (see `#onRoute`). */
|
|
102
|
+
const DIRECT = Symbol('znight.direct')
|
|
103
|
+
|
|
104
|
+
/** The hooks that run between `onRequest` and the route handler. */
|
|
105
|
+
const HANDLER_HOOKS = ['onRequest', 'preParsing', 'preValidation', 'preHandler'] as const
|
|
106
|
+
|
|
107
|
+
/** What `#onRoute` records about a Znight route (in its `config`). */
|
|
108
|
+
interface DirectRoute {
|
|
109
|
+
readonly handler: AdapterHandler
|
|
110
|
+
/** No route-level `onSend` or `onResponse` hook. */
|
|
111
|
+
readonly bare: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How the requests of a Fastify route context are served, decided on its first request (see
|
|
116
|
+
* `#lane`): `handler` from the `onRequest` hook, and `raw` when a text reply may be written on the
|
|
117
|
+
* raw response.
|
|
118
|
+
*/
|
|
119
|
+
interface Lane {
|
|
120
|
+
readonly handler: AdapterHandler
|
|
121
|
+
readonly raw: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The fields of Fastify's route context (`request[Symbol('fastify.context')]`) read by the adapter.
|
|
126
|
+
* They are internal: the adapter only skips Fastify's lifecycle when they have the expected shape.
|
|
127
|
+
*/
|
|
128
|
+
interface RouteContext {
|
|
129
|
+
readonly config?: Readonly<Record<PropertyKey, unknown>>
|
|
130
|
+
readonly preParsing?: unknown
|
|
131
|
+
readonly preValidation?: unknown
|
|
132
|
+
readonly preHandler?: unknown
|
|
133
|
+
readonly onSend?: unknown
|
|
134
|
+
readonly onResponse?: unknown
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The keys of Fastify's internal request and reply state read by the adapter (`null`: not found). */
|
|
138
|
+
interface InternalKeys {
|
|
139
|
+
/** The route context of a request. */
|
|
140
|
+
readonly context: symbol | null
|
|
141
|
+
/** The headers set with `reply.header()`. */
|
|
142
|
+
readonly headers: symbol | null
|
|
143
|
+
/** The trailers set with `reply.trailer()`. */
|
|
144
|
+
readonly trailers: symbol | null
|
|
145
|
+
/** The serializer set with `reply.serializer()` (`null` when none). */
|
|
146
|
+
readonly serializer: symbol | null
|
|
147
|
+
}
|
|
148
|
+
|
|
90
149
|
class FastifyHttpAdapter implements FastifyAdapter {
|
|
91
150
|
readonly name = 'fastify' as const
|
|
92
151
|
readonly instance: FastifyInstance
|
|
93
152
|
readonly #routes: AdapterRoute[] = []
|
|
94
153
|
readonly #tracker: ConnectionTracker
|
|
154
|
+
/** The Fastify handler of each Znight route (before Fastify binds it). */
|
|
155
|
+
readonly #handlers = new Map<unknown, AdapterHandler>()
|
|
156
|
+
/** By Fastify route context: how its requests are served (`null`: through Fastify's lifecycle). */
|
|
157
|
+
readonly #lanes = new WeakMap<object, Lane | null>()
|
|
158
|
+
#keys: InternalKeys | undefined
|
|
95
159
|
#fallback: FallbackHandler | undefined
|
|
96
160
|
#ready: Promise<void> | undefined
|
|
97
161
|
#closing: Promise<void> | undefined
|
|
@@ -130,26 +194,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
130
194
|
async #register(): Promise<void> {
|
|
131
195
|
const routes = this.#routes
|
|
132
196
|
await this.instance.register((child, _options, done) => {
|
|
133
|
-
// Znight reads and parses bodies itself: accept every content type without consuming it
|
|
197
|
+
// Znight reads and parses bodies itself: accept every content type without consuming it. The
|
|
198
|
+
// RegExp parser matches every content type like '*' does, but Fastify caches what a RegExp
|
|
199
|
+
// matched (it parses the content type again on every request to find the '*' parser)
|
|
134
200
|
child.removeAllContentTypeParsers()
|
|
135
|
-
child.addContentTypeParser('*',
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (contentType !== undefined && request.mediaType === undefined) {
|
|
141
|
-
HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
|
|
142
|
-
delete request.raw.headers['content-type']
|
|
143
|
-
}
|
|
144
|
-
done()
|
|
145
|
-
})
|
|
201
|
+
child.addContentTypeParser('*', ignoreBody)
|
|
202
|
+
child.addContentTypeParser(/^/, ignoreBody)
|
|
203
|
+
child.addHook('onRequest', this.#onRequest)
|
|
204
|
+
// added last: sees the route options once every other `onRoute` hook changed them
|
|
205
|
+
child.addHook('onRoute', this.#onRoute)
|
|
146
206
|
for (const route of routes) {
|
|
147
207
|
const handler = route.handler
|
|
208
|
+
const fastifyHandler = (request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
|
|
209
|
+
this.#reply(reply, handler(adapterRequest(request, reply)))
|
|
210
|
+
this.#handlers.set(fastifyHandler, handler)
|
|
148
211
|
child.route({
|
|
149
212
|
method: route.method,
|
|
150
213
|
url: toFastifyPath(route.path),
|
|
151
214
|
exposeHeadRoute: false,
|
|
152
|
-
handler:
|
|
215
|
+
handler: fastifyHandler,
|
|
153
216
|
})
|
|
154
217
|
}
|
|
155
218
|
child.setNotFoundHandler((request, reply) =>
|
|
@@ -163,6 +226,126 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
163
226
|
await this.instance.ready()
|
|
164
227
|
}
|
|
165
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Records in the `config` of a Znight route whether it may be served from the `onRequest` hook:
|
|
231
|
+
* its handler is still the adapter's (no `onRoute` hook wrapped it) and no route-level hook or
|
|
232
|
+
* schema runs between `onRequest` and the handler. The hooks of the instance are checked on the
|
|
233
|
+
* first request (see `#lane`).
|
|
234
|
+
*/
|
|
235
|
+
readonly #onRoute = (options: RouteOptions): void => {
|
|
236
|
+
const handler = this.#handlers.get(options.handler)
|
|
237
|
+
if (handler === undefined || options.schema !== undefined) return
|
|
238
|
+
if (HANDLER_HOOKS.some(hook => hasHooks(options[hook]))) return
|
|
239
|
+
const route: DirectRoute = { handler, bare: !hasHooks(options.onSend) && !hasHooks(options.onResponse) }
|
|
240
|
+
options.config = { ...options.config, [DIRECT]: route }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The `onRequest` hook of the Znight context. A route request that no later hook, schema or
|
|
245
|
+
* tracing subscriber needs (see `#lane`) is served from here: Fastify skips its content type
|
|
246
|
+
* parsing and the steps between this hook and the handler, which do nothing for Znight routes.
|
|
247
|
+
* Otherwise the request goes on through Fastify's lifecycle.
|
|
248
|
+
*/
|
|
249
|
+
readonly #onRequest = (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction): void => {
|
|
250
|
+
const lane = this.#lane(request, reply)
|
|
251
|
+
if (lane !== null && !HANDLER_CHANNEL.hasSubscribers) {
|
|
252
|
+
this.#serve(lane, request, reply)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
// Fastify answers 415 to a content type it cannot parse (`foo`, an empty one) before the route
|
|
256
|
+
// runs: hide such a header from Fastify (Znight reads it, and parses the body as bytes)
|
|
257
|
+
const contentType = request.headers['content-type']
|
|
258
|
+
if (contentType !== undefined && request.mediaType === undefined) {
|
|
259
|
+
HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
|
|
260
|
+
delete request.raw.headers['content-type']
|
|
261
|
+
}
|
|
262
|
+
done()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* How the requests of the route of `request` are served, decided once per route context: from the
|
|
267
|
+
* `onRequest` hook when `#onRoute` marked the route and the instance has no `preParsing`,
|
|
268
|
+
* `preValidation` or `preHandler` hook (the `onRequest` hook of the Znight context is then the
|
|
269
|
+
* last one); with a text reply written on the raw response when no `onSend` or `onResponse` hook
|
|
270
|
+
* runs either (see `#writeRaw`). `null`: through Fastify's lifecycle (404s, hooks, a Fastify
|
|
271
|
+
* version whose route context differs).
|
|
272
|
+
*/
|
|
273
|
+
#lane(request: FastifyRequest, reply: FastifyReply): Lane | null {
|
|
274
|
+
this.#keys ??= {
|
|
275
|
+
context: ownSymbol(request, 'fastify.context'),
|
|
276
|
+
headers: ownSymbol(reply, 'fastify.reply.headers'),
|
|
277
|
+
trailers: ownSymbol(reply, 'fastify.reply.trailers'),
|
|
278
|
+
serializer: ownSymbol(reply, 'fastify.reply.serializer'),
|
|
279
|
+
}
|
|
280
|
+
const key = this.#keys.context
|
|
281
|
+
const context = key === null ? undefined : (request as unknown as Record<symbol, unknown>)[key]
|
|
282
|
+
if (typeof context !== 'object' || context === null) return null
|
|
283
|
+
let lane = this.#lanes.get(context)
|
|
284
|
+
if (lane === undefined) {
|
|
285
|
+
lane = laneOf(context as RouteContext, this.#keys)
|
|
286
|
+
this.#lanes.set(context, lane)
|
|
287
|
+
}
|
|
288
|
+
return lane
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Serves a route request from the `onRequest` hook (see `#lane`). */
|
|
292
|
+
#serve(lane: Lane, request: FastifyRequest, reply: FastifyReply): void {
|
|
293
|
+
let pending: Promise<AdapterReply>
|
|
294
|
+
try {
|
|
295
|
+
pending = lane.handler(new FastifyAdapterRequest(request, reply, reply))
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// what Fastify does when a route handler throws
|
|
298
|
+
reply.send(asError(error))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
pending.then(
|
|
302
|
+
result => this.#settle(lane, reply, result),
|
|
303
|
+
() => this.#settle(lane, reply, internalError()),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Writes the reply of a request served from the `onRequest` hook; a failure is handled like Fastify does. */
|
|
308
|
+
#settle(lane: Lane, reply: FastifyReply, result: AdapterReply): void {
|
|
309
|
+
try {
|
|
310
|
+
if (!lane.raw || !this.#writeRaw(reply, result)) this.#write(reply, result)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// what Fastify does when the promise of a route handler rejects
|
|
313
|
+
if (reply.sent) reply.log.error({ err: error }, 'Promise errored, but reply.sent = true was set')
|
|
314
|
+
else reply.send(asError(error))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Writes a text reply on the raw response, as `reply.send(text)` would without `onSend` hook: one
|
|
320
|
+
* `writeHead()` with the same headers in the same order, then the text. Only when nothing set
|
|
321
|
+
* reply headers or trailers before (an `onRequest` hook) and Fastify would send the headers and
|
|
322
|
+
* text unchanged (content type, exact `content-length`). Returns whether it wrote the reply.
|
|
323
|
+
*/
|
|
324
|
+
#writeRaw(reply: FastifyReply, result: AdapterReply): boolean {
|
|
325
|
+
const text = result.text
|
|
326
|
+
const body = result.body
|
|
327
|
+
if (text === undefined || !(body instanceof Uint8Array) || result.raw === true) return false
|
|
328
|
+
const headers = result.headers
|
|
329
|
+
const length = headers['content-length']
|
|
330
|
+
if (typeof length !== 'string' || Number(length) !== body.byteLength) return false
|
|
331
|
+
// a content type Fastify cannot parse is replaced by `reply.send()` (text/plain)
|
|
332
|
+
const type = headers['content-type']
|
|
333
|
+
if (!sendsTextAsIs(type) || (type !== JSON_TYPE && !MEDIA_TYPE.test(type as string))) return false
|
|
334
|
+
const response = reply.raw
|
|
335
|
+
if (reply.sent || response.destroyed || response.headersSent || reply.request.raw.httpVersionMajor !== 1) {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
const keys = this.#keys as InternalKeys
|
|
339
|
+
const state = reply as unknown as Record<symbol, unknown>
|
|
340
|
+
if (keys.headers === null || keys.trailers === null || state[keys.trailers] !== null) return false
|
|
341
|
+
// a serializer set by a hook transforms text sent with `reply.send()`
|
|
342
|
+
if (keys.serializer === null || state[keys.serializer] !== null) return false
|
|
343
|
+
if (!isEmptyRecord(state[keys.headers])) return false
|
|
344
|
+
response.writeHead(result.status, headers as OutgoingHttpHeaders)
|
|
345
|
+
response.end(text)
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
|
|
166
349
|
#dispatchFallback(request: FastifyRequest, reply: FastifyReply, error: unknown): Promise<AdapterReply> {
|
|
167
350
|
if (this.#fallback === undefined) return Promise.reject(error ?? new Error('No fallback handler'))
|
|
168
351
|
return this.#fallback(adapterRequest(request, undefined, reply), error)
|
|
@@ -173,15 +356,17 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
173
356
|
try {
|
|
174
357
|
result = await pending
|
|
175
358
|
} catch {
|
|
176
|
-
result =
|
|
177
|
-
status: 500,
|
|
178
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
179
|
-
body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
|
|
180
|
-
}
|
|
359
|
+
result = internalError()
|
|
181
360
|
}
|
|
361
|
+
this.#write(reply, result)
|
|
362
|
+
return reply
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Writes a reply through Fastify (`reply.send()`: `onSend` hooks...). */
|
|
366
|
+
#write(reply: FastifyReply, result: AdapterReply): void {
|
|
182
367
|
if (result.raw === true || reply.sent || reply.raw.destroyed) {
|
|
183
368
|
discardStream(result.body instanceof ReadableStream ? result.body : null)
|
|
184
|
-
return
|
|
369
|
+
return
|
|
185
370
|
}
|
|
186
371
|
if (result.eventStream === true) {
|
|
187
372
|
// no onSend hook (compression, ETag...) may rewrite an event stream: write it on the raw
|
|
@@ -191,19 +376,24 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
191
376
|
const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
|
|
192
377
|
reply.hijack()
|
|
193
378
|
writeNodeReply(reply.raw, { ...result, headers })
|
|
194
|
-
return
|
|
379
|
+
return
|
|
195
380
|
}
|
|
196
381
|
reply.code(result.status)
|
|
197
|
-
|
|
382
|
+
const headers = result.headers
|
|
383
|
+
for (const name of Object.keys(headers)) reply.header(name, headers[name])
|
|
198
384
|
const body = result.body
|
|
199
|
-
if (body === null)
|
|
385
|
+
if (body === null) {
|
|
386
|
+
reply.send()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
200
389
|
if (body instanceof Uint8Array) {
|
|
201
390
|
// a string is written with the headers at once (bytes take a second write)
|
|
202
|
-
if (result.text !== undefined && sendsTextAsIs(
|
|
203
|
-
|
|
391
|
+
if (result.text !== undefined && sendsTextAsIs(headers['content-type'])) reply.send(result.text)
|
|
392
|
+
else reply.send(Buffer.isBuffer(body) ? body : Buffer.from(body.buffer, body.byteOffset, body.byteLength))
|
|
393
|
+
return
|
|
204
394
|
}
|
|
205
395
|
const stream = body instanceof Blob ? body.stream() : body
|
|
206
|
-
|
|
396
|
+
reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
|
|
207
397
|
}
|
|
208
398
|
|
|
209
399
|
async listen(port: number, host: string): Promise<HttpServerAddress> {
|
|
@@ -241,6 +431,53 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
241
431
|
}
|
|
242
432
|
}
|
|
243
433
|
|
|
434
|
+
/**
|
|
435
|
+
* The lane of a route context (see `FastifyHttpAdapter.#lane`). Once the instance is ready, every
|
|
436
|
+
* lifecycle hook list of a context is `null` or a non-empty array.
|
|
437
|
+
*/
|
|
438
|
+
function laneOf(context: RouteContext, keys: InternalKeys): Lane | null {
|
|
439
|
+
const route = context.config?.[DIRECT] as DirectRoute | undefined
|
|
440
|
+
if (route === undefined) return null
|
|
441
|
+
if (context.preParsing !== null || context.preValidation !== null || context.preHandler !== null) return null
|
|
442
|
+
const raw = route.bare && context.onSend === null && context.onResponse === null
|
|
443
|
+
return { handler: route.handler, raw: raw && keys.headers !== null && keys.trailers !== null }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The content type parser of the Znight context: the body is left unread (Znight reads it). */
|
|
447
|
+
function ignoreBody(_request: FastifyRequest, _payload: unknown, done: (error: null, body: undefined) => void): void {
|
|
448
|
+
done(null, undefined)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Whether a route option holds hooks (a function or a non-empty array). */
|
|
452
|
+
function hasHooks(value: unknown): boolean {
|
|
453
|
+
return value !== undefined && value !== null && !(Array.isArray(value) && value.length === 0)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** An own symbol-keyed property of a Fastify object, by the symbol's description (internal state). */
|
|
457
|
+
function ownSymbol(target: object, description: string): symbol | null {
|
|
458
|
+
for (const symbol of Object.getOwnPropertySymbols(target)) if (symbol.description === description) return symbol
|
|
459
|
+
return null
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isEmptyRecord(value: unknown): boolean {
|
|
463
|
+
if (typeof value !== 'object' || value === null) return false
|
|
464
|
+
for (const _key in value) return false
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function asError(error: unknown): Error {
|
|
469
|
+
return error instanceof Error ? error : new Error(String(error), { cause: error })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The last-resort 500 of a handler promise that rejected (the pipeline maps errors: should not happen). */
|
|
473
|
+
function internalError(): AdapterReply {
|
|
474
|
+
return {
|
|
475
|
+
status: 500,
|
|
476
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
477
|
+
body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
244
481
|
/** The JSON content type of the plugin (with a charset). */
|
|
245
482
|
const JSON_TYPE = 'application/json; charset=utf-8'
|
|
246
483
|
|
|
@@ -248,6 +485,9 @@ const JSON_TYPE = 'application/json; charset=utf-8'
|
|
|
248
485
|
* Whether Fastify sends a string with this content type unchanged: it adds a charset to a JSON
|
|
249
486
|
* type without one, and a content type to a string without one.
|
|
250
487
|
*/
|
|
488
|
+
/** A `type/subtype` media type, optionally followed by parameters: what Fastify's `ContentType` accepts. */
|
|
489
|
+
const MEDIA_TYPE = /^\s*[\w!#$%&'*+.^`|~-]+\/[\w!#$%&'*+.^`|~-]+\s*(?:;|$)/
|
|
490
|
+
|
|
251
491
|
function sendsTextAsIs(type: HeaderValue | undefined): boolean {
|
|
252
492
|
if (typeof type !== 'string') return false
|
|
253
493
|
if (type === JSON_TYPE) return true
|