@znight/http 0.1.1 → 0.1.3
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/LICENSE +1 -1
- package/README.md +83 -32
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/compiler.mjs +82 -13
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +254 -42
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +272 -67
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +930 -278
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.d.mts +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/adapter.ts +6 -0
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +329 -44
- package/src/adapters/hono.ts +341 -86
- package/src/adapters/node-io.ts +185 -82
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/generate.ts +127 -22
- package/src/context.ts +151 -78
- package/src/index.ts +1 -1
- package/src/pipeline.ts +809 -208
- package/src/plugin.ts +10 -4
- package/src/query.ts +7 -1
- package/src/response.ts +6 -8
- package/src/serialize.ts +83 -16
- 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.3",
|
|
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.3"
|
|
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.3"
|
|
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.3"
|
|
122
122
|
},
|
|
123
123
|
"znight": {
|
|
124
124
|
"apiVersion": 1,
|
package/src/adapter.ts
CHANGED
|
@@ -81,6 +81,12 @@ export interface AdapterReply {
|
|
|
81
81
|
readonly headers: Readonly<Record<string, HeaderValue>>
|
|
82
82
|
/** `null` for HEAD requests and bodyless statuses (headers such as `content-length` are kept). */
|
|
83
83
|
readonly body: ReplyBody
|
|
84
|
+
/**
|
|
85
|
+
* The text `body` holds encoded as UTF-8, for a body serialised from a string (JSON, text).
|
|
86
|
+
* Adapters writing strings MAY send it instead of `body`, which writes the same bytes: Node.js then
|
|
87
|
+
* writes the headers and the body at once (bytes take a second write).
|
|
88
|
+
*/
|
|
89
|
+
readonly text?: string
|
|
84
90
|
/**
|
|
85
91
|
* The `Blob` (file) that `body` streams, for adapters able to send it natively (`sendfile`, exact
|
|
86
92
|
* length: `new Response(blob)` on `Bun.serve()`). An adapter sending it instead of reading `body`
|
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,
|
|
@@ -17,13 +19,17 @@ import type {
|
|
|
17
19
|
InjectStreamRequest,
|
|
18
20
|
InjectStreamResponse,
|
|
19
21
|
} from '../adapter.ts'
|
|
22
|
+
import type { HeaderValue } from '../exceptions.ts'
|
|
20
23
|
|
|
24
|
+
import { tracingChannel } from 'node:diagnostics_channel'
|
|
21
25
|
import { Readable } from 'node:stream'
|
|
22
26
|
import Fastify, {
|
|
23
27
|
type FastifyInstance,
|
|
24
28
|
type FastifyReply,
|
|
25
29
|
type FastifyRequest,
|
|
26
30
|
type FastifyServerOptions,
|
|
31
|
+
type HookHandlerDoneFunction,
|
|
32
|
+
type RouteOptions,
|
|
27
33
|
} from 'fastify'
|
|
28
34
|
|
|
29
35
|
import { toFastifyPath } from '../path.ts'
|
|
@@ -86,11 +92,70 @@ const MAX_PARAM_LENGTH = 8192
|
|
|
86
92
|
/** `content-type` headers hidden from Fastify (see `#register`), by request. */
|
|
87
93
|
const HIDDEN_CONTENT_TYPES = new WeakMap<object, string>()
|
|
88
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
|
+
|
|
89
149
|
class FastifyHttpAdapter implements FastifyAdapter {
|
|
90
150
|
readonly name = 'fastify' as const
|
|
91
151
|
readonly instance: FastifyInstance
|
|
92
152
|
readonly #routes: AdapterRoute[] = []
|
|
93
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
|
|
94
159
|
#fallback: FallbackHandler | undefined
|
|
95
160
|
#ready: Promise<void> | undefined
|
|
96
161
|
#closing: Promise<void> | undefined
|
|
@@ -129,26 +194,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
129
194
|
async #register(): Promise<void> {
|
|
130
195
|
const routes = this.#routes
|
|
131
196
|
await this.instance.register((child, _options, done) => {
|
|
132
|
-
// 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)
|
|
133
200
|
child.removeAllContentTypeParsers()
|
|
134
|
-
child.addContentTypeParser('*',
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (contentType !== undefined && request.mediaType === undefined) {
|
|
140
|
-
HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
|
|
141
|
-
delete request.raw.headers['content-type']
|
|
142
|
-
}
|
|
143
|
-
done()
|
|
144
|
-
})
|
|
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)
|
|
145
206
|
for (const route of routes) {
|
|
146
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)
|
|
147
211
|
child.route({
|
|
148
212
|
method: route.method,
|
|
149
213
|
url: toFastifyPath(route.path),
|
|
150
214
|
exposeHeadRoute: false,
|
|
151
|
-
handler:
|
|
215
|
+
handler: fastifyHandler,
|
|
152
216
|
})
|
|
153
217
|
}
|
|
154
218
|
child.setNotFoundHandler((request, reply) =>
|
|
@@ -162,6 +226,126 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
162
226
|
await this.instance.ready()
|
|
163
227
|
}
|
|
164
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
|
+
|
|
165
349
|
#dispatchFallback(request: FastifyRequest, reply: FastifyReply, error: unknown): Promise<AdapterReply> {
|
|
166
350
|
if (this.#fallback === undefined) return Promise.reject(error ?? new Error('No fallback handler'))
|
|
167
351
|
return this.#fallback(adapterRequest(request, undefined, reply), error)
|
|
@@ -172,15 +356,17 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
172
356
|
try {
|
|
173
357
|
result = await pending
|
|
174
358
|
} catch {
|
|
175
|
-
result =
|
|
176
|
-
status: 500,
|
|
177
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
178
|
-
body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
|
|
179
|
-
}
|
|
359
|
+
result = internalError()
|
|
180
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 {
|
|
181
367
|
if (result.raw === true || reply.sent || reply.raw.destroyed) {
|
|
182
368
|
discardStream(result.body instanceof ReadableStream ? result.body : null)
|
|
183
|
-
return
|
|
369
|
+
return
|
|
184
370
|
}
|
|
185
371
|
if (result.eventStream === true) {
|
|
186
372
|
// no onSend hook (compression, ETag...) may rewrite an event stream: write it on the raw
|
|
@@ -189,16 +375,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
189
375
|
const native = lowerCaseHeaders(reply.getHeaders())
|
|
190
376
|
const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
|
|
191
377
|
reply.hijack()
|
|
192
|
-
|
|
193
|
-
return
|
|
378
|
+
writeNodeReply(reply.raw, { ...result, headers })
|
|
379
|
+
return
|
|
194
380
|
}
|
|
195
381
|
reply.code(result.status)
|
|
196
|
-
|
|
382
|
+
const headers = result.headers
|
|
383
|
+
for (const name of Object.keys(headers)) reply.header(name, headers[name])
|
|
197
384
|
const body = result.body
|
|
198
|
-
if (body === null)
|
|
199
|
-
|
|
385
|
+
if (body === null) {
|
|
386
|
+
reply.send()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (body instanceof Uint8Array) {
|
|
390
|
+
// a string is written with the headers at once (bytes take a second write)
|
|
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
|
|
394
|
+
}
|
|
200
395
|
const stream = body instanceof Blob ? body.stream() : body
|
|
201
|
-
|
|
396
|
+
reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
|
|
202
397
|
}
|
|
203
398
|
|
|
204
399
|
async listen(port: number, host: string): Promise<HttpServerAddress> {
|
|
@@ -236,6 +431,70 @@ class FastifyHttpAdapter implements FastifyAdapter {
|
|
|
236
431
|
}
|
|
237
432
|
}
|
|
238
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
|
+
|
|
481
|
+
/** The JSON content type of the plugin (with a charset). */
|
|
482
|
+
const JSON_TYPE = 'application/json; charset=utf-8'
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether Fastify sends a string with this content type unchanged: it adds a charset to a JSON
|
|
486
|
+
* type without one, and a content type to a string without one.
|
|
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
|
+
|
|
491
|
+
function sendsTextAsIs(type: HeaderValue | undefined): boolean {
|
|
492
|
+
if (typeof type !== 'string') return false
|
|
493
|
+
if (type === JSON_TYPE) return true
|
|
494
|
+
const semicolon = type.indexOf(';')
|
|
495
|
+
return !(semicolon === -1 ? type : type.slice(0, semicolon)).toLowerCase().includes('json')
|
|
496
|
+
}
|
|
497
|
+
|
|
239
498
|
function lowerCaseHeaders(
|
|
240
499
|
headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
|
|
241
500
|
): Record<string, string | string[]> {
|
|
@@ -257,24 +516,50 @@ function adapterRequest(
|
|
|
257
516
|
HIDDEN_CONTENT_TYPES.delete(request.raw)
|
|
258
517
|
request.raw.headers['content-type'] = hidden
|
|
259
518
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
519
|
+
return new FastifyAdapterRequest(request, reply, response)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** A Fastify request as an {@link AdapterRequest} (a class: no closure per request). */
|
|
523
|
+
class FastifyAdapterRequest implements AdapterRequest {
|
|
524
|
+
readonly method: string
|
|
525
|
+
readonly url: string
|
|
526
|
+
readonly headers: Readonly<Record<string, string | undefined>>
|
|
527
|
+
readonly ip: string | undefined
|
|
528
|
+
readonly remoteAddress: string | undefined
|
|
529
|
+
readonly encrypted: boolean
|
|
530
|
+
readonly raw: FastifyRequest
|
|
531
|
+
/** `@Res()`: the reply of a route, `undefined` for the fallback handler. */
|
|
532
|
+
readonly rawResponse: FastifyReply | undefined
|
|
533
|
+
/** The reply whose disconnection aborts the request. */
|
|
534
|
+
readonly #response: FastifyReply | undefined
|
|
535
|
+
#body: Promise<Uint8Array> | undefined
|
|
536
|
+
|
|
537
|
+
constructor(request: FastifyRequest, reply: FastifyReply | undefined, response: FastifyReply | undefined) {
|
|
538
|
+
this.method = request.raw.method ?? request.method
|
|
539
|
+
this.url = request.raw.url ?? request.url
|
|
540
|
+
this.headers = normalizeNodeHeaders(request.headers)
|
|
541
|
+
this.ip = request.ip
|
|
542
|
+
this.remoteAddress = request.raw.socket?.remoteAddress
|
|
543
|
+
this.encrypted = isEncrypted(request.raw)
|
|
544
|
+
this.raw = request
|
|
545
|
+
this.rawResponse = reply
|
|
546
|
+
this.#response = response
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
readBody(limit: number): Promise<Uint8Array> {
|
|
550
|
+
this.#body ??= readNodeBody(this.raw.raw, limit)
|
|
551
|
+
return this.#body
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
streamBody(limit: number): ReadableStream<Uint8Array> {
|
|
555
|
+
return nodeBodyStream(this.raw.raw, limit)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
rawStatus(): number | undefined {
|
|
559
|
+
return this.rawResponse?.statusCode
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
onAbort(listener: () => void): void {
|
|
563
|
+
if (this.#response !== undefined) onResponseAbort(this.#response.raw, listener)
|
|
279
564
|
}
|
|
280
565
|
}
|