@znight/http 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * `@znight/http/fastify`: serves Znight routes with [Fastify](https://fastify.dev) 5 (optional
3
+ * peer dependency `fastify`). Routing uses Fastify's router; body parsing, validation and
4
+ * serialisation stay Znight's, so responses are identical to the other adapters.
5
+ */
6
+ import type {
7
+ AdapterCloseOptions,
8
+ AdapterHandler,
9
+ AdapterReply,
10
+ AdapterRequest,
11
+ AdapterRoute,
12
+ FallbackHandler,
13
+ HttpAdapter,
14
+ HttpServerAddress,
15
+ InjectRequest,
16
+ InjectResponse,
17
+ InjectStreamRequest,
18
+ InjectStreamResponse,
19
+ } from '../adapter.ts'
20
+
21
+ import { Readable } from 'node:stream'
22
+ import Fastify, {
23
+ type FastifyInstance,
24
+ type FastifyReply,
25
+ type FastifyRequest,
26
+ type FastifyServerOptions,
27
+ } from 'fastify'
28
+
29
+ import { toFastifyPath } from '../path.ts'
30
+ import { discardStream } from '../stream.ts'
31
+ import {
32
+ ConnectionTracker,
33
+ isEncrypted,
34
+ nodeBodyStream,
35
+ normalizeNodeHeaders,
36
+ onResponseAbort,
37
+ readNodeBody,
38
+ withNativeCookies,
39
+ writeNodeReply,
40
+ } from './node-io.ts'
41
+ import {
42
+ bufferInjectStream,
43
+ type LightInjectOptions,
44
+ lightInjectOptions,
45
+ lightStreamResponse,
46
+ serverAddress,
47
+ } from './shared.ts'
48
+
49
+ /** Options of {@link fastifyAdapter}. */
50
+ export interface FastifyAdapterOptions {
51
+ /**
52
+ * An existing instance (with your plugins and hooks). Znight registers its routes in an
53
+ * encapsulated child context (its own body handling and 404 handler) and closes the instance on
54
+ * shutdown. Malformed URLs are then answered by Fastify's `frameworkErrors` handler.
55
+ */
56
+ readonly instance?: FastifyInstance
57
+ /**
58
+ * Options of the instance created when `instance` is absent. Znight sets `exposeHeadRoutes: false`
59
+ * (HEAD is served by Znight), a `maxParamLength` of 8 KiB and a `frameworkErrors` handler.
60
+ */
61
+ readonly options?: FastifyServerOptions
62
+ }
63
+
64
+ /** The Fastify adapter. */
65
+ export interface FastifyAdapter extends HttpAdapter {
66
+ readonly name: 'fastify'
67
+ /** The Fastify instance. */
68
+ readonly instance: FastifyInstance
69
+ }
70
+
71
+ /**
72
+ * Creates a Fastify adapter.
73
+ *
74
+ * ```ts
75
+ * import { fastifyAdapter } from '@znight/http/fastify'
76
+ *
77
+ * HttpPlugin.configure({ adapter: fastifyAdapter({ options: { trustProxy: true } }) })
78
+ * ```
79
+ */
80
+ export function fastifyAdapter(options: FastifyAdapterOptions = {}): FastifyAdapter {
81
+ return new FastifyHttpAdapter(options)
82
+ }
83
+
84
+ const MAX_PARAM_LENGTH = 8192
85
+
86
+ /** `content-type` headers hidden from Fastify (see `#register`), by request. */
87
+ const HIDDEN_CONTENT_TYPES = new WeakMap<object, string>()
88
+
89
+ class FastifyHttpAdapter implements FastifyAdapter {
90
+ readonly name = 'fastify' as const
91
+ readonly instance: FastifyInstance
92
+ readonly #routes: AdapterRoute[] = []
93
+ readonly #tracker: ConnectionTracker
94
+ #fallback: FallbackHandler | undefined
95
+ #ready: Promise<void> | undefined
96
+ #closing: Promise<void> | undefined
97
+
98
+ constructor(options: FastifyAdapterOptions) {
99
+ if (options.instance === undefined) {
100
+ const serverOptions = options.options ?? {}
101
+ this.instance = Fastify({
102
+ exposeHeadRoutes: false,
103
+ frameworkErrors: (error, request, reply) => {
104
+ this.#reply(reply, this.#dispatchFallback(request, reply, error)).catch(() => undefined)
105
+ },
106
+ ...serverOptions,
107
+ routerOptions: { maxParamLength: MAX_PARAM_LENGTH, ...serverOptions.routerOptions },
108
+ })
109
+ } else {
110
+ this.instance = options.instance
111
+ }
112
+ this.#tracker = new ConnectionTracker(this.instance.server)
113
+ }
114
+
115
+ registerRoute(route: AdapterRoute): void {
116
+ if (this.#ready !== undefined) throw new Error('fastifyAdapter: routes must be registered before listen()/inject()')
117
+ this.#routes.push(route)
118
+ }
119
+
120
+ setFallbackHandler(handler: FallbackHandler): void {
121
+ this.#fallback = handler
122
+ }
123
+
124
+ #prepare(): Promise<void> {
125
+ this.#ready ??= this.#register()
126
+ return this.#ready
127
+ }
128
+
129
+ async #register(): Promise<void> {
130
+ const routes = this.#routes
131
+ await this.instance.register((child, _options, done) => {
132
+ // Znight reads and parses bodies itself: accept every content type without consuming it
133
+ child.removeAllContentTypeParsers()
134
+ child.addContentTypeParser('*', (_request, _payload, done) => done(null, undefined))
135
+ // Fastify answers 415 to a content type it cannot parse (`foo`, an empty one) before the route
136
+ // runs: hide such a header from Fastify (Znight reads it, and parses the body as bytes)
137
+ child.addHook('onRequest', (request, _reply, done) => {
138
+ const contentType = request.headers['content-type']
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
+ })
145
+ for (const route of routes) {
146
+ const handler = route.handler
147
+ child.route({
148
+ method: route.method,
149
+ url: toFastifyPath(route.path),
150
+ exposeHeadRoute: false,
151
+ handler: (request, reply) => this.#reply(reply, handler(adapterRequest(request, reply))),
152
+ })
153
+ }
154
+ child.setNotFoundHandler((request, reply) =>
155
+ this.#reply(reply, this.#dispatchFallback(request, reply, undefined)),
156
+ )
157
+ child.setErrorHandler((error, request, reply) =>
158
+ this.#reply(reply, this.#dispatchFallback(request, reply, error)),
159
+ )
160
+ done()
161
+ })
162
+ await this.instance.ready()
163
+ }
164
+
165
+ #dispatchFallback(request: FastifyRequest, reply: FastifyReply, error: unknown): Promise<AdapterReply> {
166
+ if (this.#fallback === undefined) return Promise.reject(error ?? new Error('No fallback handler'))
167
+ return this.#fallback(adapterRequest(request, undefined, reply), error)
168
+ }
169
+
170
+ async #reply(reply: FastifyReply, pending: ReturnType<AdapterHandler>): Promise<FastifyReply> {
171
+ let result: AdapterReply
172
+ try {
173
+ result = await pending
174
+ } 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
+ }
180
+ }
181
+ if (result.raw === true || reply.sent || reply.raw.destroyed) {
182
+ discardStream(result.body instanceof ReadableStream ? result.body : null)
183
+ return reply
184
+ }
185
+ if (result.eventStream === true) {
186
+ // no onSend hook (compression, ETag...) may rewrite an event stream: write it on the raw
187
+ // response, with the headers Fastify hooks already set (`onRequest`...) under the plugin's,
188
+ // and their cookies before the plugin's (as reply.header('set-cookie') does on the other path)
189
+ const native = lowerCaseHeaders(reply.getHeaders())
190
+ const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
191
+ reply.hijack()
192
+ void writeNodeReply(reply.raw, { ...result, headers })
193
+ return reply
194
+ }
195
+ reply.code(result.status)
196
+ for (const name of Object.keys(result.headers)) reply.header(name, result.headers[name])
197
+ const body = result.body
198
+ if (body === null) return reply.send()
199
+ if (body instanceof Uint8Array) return reply.send(Buffer.from(body.buffer, body.byteOffset, body.byteLength))
200
+ const stream = body instanceof Blob ? body.stream() : body
201
+ return reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
202
+ }
203
+
204
+ async listen(port: number, host: string): Promise<HttpServerAddress> {
205
+ await this.#prepare()
206
+ this.#closing = undefined
207
+ await this.instance.listen({ port, host })
208
+ const address = this.instance.server.address()
209
+ if (address === null || typeof address === 'string') return serverAddress(host, port)
210
+ return serverAddress(address.address, address.port, address.family)
211
+ }
212
+
213
+ close(options: AdapterCloseOptions = {}): Promise<void> {
214
+ if (this.#closing === undefined) {
215
+ this.#tracker.closeIdle()
216
+ this.#closing = this.instance.close()
217
+ }
218
+ if (options.force === true) {
219
+ this.instance.server.closeAllConnections?.()
220
+ this.#tracker.destroyAll()
221
+ }
222
+ return this.#closing
223
+ }
224
+
225
+ inject(request: InjectRequest): Promise<InjectResponse> {
226
+ return bufferInjectStream(stream => this.injectStream(stream), request)
227
+ }
228
+
229
+ async injectStream(request: InjectStreamRequest): Promise<InjectStreamResponse> {
230
+ await this.#prepare()
231
+ const options: LightInjectOptions = await lightInjectOptions(request)
232
+ // Fastify forwards the options to light-my-request (`payloadAsStream`)
233
+ const inject = this.instance.inject as unknown as (options: LightInjectOptions) => Promise<unknown>
234
+ const response = await inject.call(this.instance, options)
235
+ return lightStreamResponse(response as Parameters<typeof lightStreamResponse>[0], request.signal)
236
+ }
237
+ }
238
+
239
+ function lowerCaseHeaders(
240
+ headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
241
+ ): Record<string, string | string[]> {
242
+ const result: Record<string, string | string[]> = {}
243
+ for (const [name, value] of Object.entries(headers)) {
244
+ if (value !== undefined) result[name.toLowerCase()] = typeof value === 'object' ? [...value] : String(value)
245
+ }
246
+ return result
247
+ }
248
+
249
+ function adapterRequest(
250
+ request: FastifyRequest,
251
+ reply: FastifyReply | undefined,
252
+ response: FastifyReply | undefined = reply,
253
+ ): AdapterRequest {
254
+ const hidden = HIDDEN_CONTENT_TYPES.get(request.raw)
255
+ if (hidden !== undefined) {
256
+ // Fastify is done with the content type: restore it (for Znight and `@Req()`)
257
+ HIDDEN_CONTENT_TYPES.delete(request.raw)
258
+ request.raw.headers['content-type'] = hidden
259
+ }
260
+ let body: Promise<Uint8Array> | undefined
261
+ return {
262
+ method: request.raw.method ?? request.method,
263
+ url: request.raw.url ?? request.url,
264
+ headers: normalizeNodeHeaders(request.headers),
265
+ ip: request.ip,
266
+ remoteAddress: request.raw.socket?.remoteAddress,
267
+ encrypted: isEncrypted(request.raw),
268
+ raw: request,
269
+ rawResponse: reply,
270
+ readBody: (limit: number): Promise<Uint8Array> => {
271
+ body ??= readNodeBody(request.raw, limit)
272
+ return body
273
+ },
274
+ streamBody: (limit: number): ReadableStream<Uint8Array> => nodeBodyStream(request.raw, limit),
275
+ rawStatus: (): number | undefined => reply?.statusCode,
276
+ onAbort: (listener: () => void): void => {
277
+ if (response !== undefined) onResponseAbort(response.raw, listener)
278
+ },
279
+ }
280
+ }