@znight/http 0.0.0-stage → 0.1.1

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
package/src/headers.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Validation of HTTP header names and values (internal). */
2
+ import type { HeaderValue } from './exceptions.ts'
3
+
4
+ /** A field name: an RFC 9110 token. */
5
+ const NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
6
+
7
+ /**
8
+ * Characters a field value may not contain: everything but HTAB, visible ASCII, space and obs-text
9
+ * (what Node.js `validateHeaderValue` accepts; Fetch `Headers` are less strict, Latin-1 only).
10
+ */
11
+ const INVALID_VALUE = /[^\t\x20-\x7e\x80-\xff]/
12
+
13
+ /** Whether `name` is a valid header name. */
14
+ export function isHeaderName(name: string): boolean {
15
+ return NAME.test(name)
16
+ }
17
+
18
+ /** Whether `value` can be sent as a header value by every adapter (no CR / LF, Latin-1 only...). */
19
+ export function isHeaderValue(value: string): boolean {
20
+ return !INVALID_VALUE.test(value)
21
+ }
22
+
23
+ /**
24
+ * The name of the first header whose name or value cannot be sent (`ctx.setHeader('x', 'a\r\nb')`,
25
+ * a non Latin-1 file name...), `undefined` when every header is valid.
26
+ */
27
+ export function findInvalidHeader(headers: Readonly<Record<string, HeaderValue | undefined>>): string | undefined {
28
+ for (const name of Object.keys(headers)) {
29
+ const value = headers[name]
30
+ if (value === undefined) continue
31
+ if (!NAME.test(name)) return name
32
+ if (typeof value === 'string') {
33
+ if (INVALID_VALUE.test(value)) return name
34
+ } else {
35
+ for (const item of value) if (INVALID_VALUE.test(String(item))) return name
36
+ }
37
+ }
38
+ return undefined
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `@znight/http`: controllers, routing, guards, interceptors and validation for Znight, with a
3
+ * built-in `node:http` server and Fastify / Express / Hono adapters (subpath exports).
4
+ */
5
+ export type {
6
+ AdapterCloseOptions,
7
+ AdapterHandler,
8
+ AdapterReply,
9
+ AdapterRequest,
10
+ AdapterRoute,
11
+ FallbackHandler,
12
+ HttpAdapter,
13
+ HttpServerAddress,
14
+ InjectRequest,
15
+ InjectResponse,
16
+ InjectStreamOptions,
17
+ InjectStreamRequest,
18
+ InjectStreamResponse,
19
+ ReplyBody,
20
+ } from './adapter.ts'
21
+ export type { HttpContext, HttpRouteInfo, RequestAbortKind, RequestId } from './context.ts'
22
+ export type {
23
+ ClearCookieOptions,
24
+ CookieDefaults,
25
+ CookieOptions,
26
+ ResponseCookie,
27
+ SetCookieInfo,
28
+ } from './cookies.ts'
29
+ export type { CorsOptions, CorsOrigin } from './cors.ts'
30
+ export type { EnforcementMode } from './decisions.ts'
31
+ export type { EventChannelOptions } from './event-channel.ts'
32
+ export type { InjectSseResponse, InjectSseWaitOptions } from './inject-sse.ts'
33
+ export type {
34
+ CallHandler,
35
+ CanActivate,
36
+ ExceptionMapper,
37
+ ExceptionMapperClass,
38
+ ExceptionMapperResult,
39
+ GuardClass,
40
+ GuardEnforcement,
41
+ HttpInterceptor,
42
+ HttpInterceptorClass,
43
+ } from './interfaces.ts'
44
+ export type {
45
+ HttpControllerDefinition,
46
+ HttpMetadata,
47
+ HttpMethod,
48
+ HttpParamDefinition,
49
+ HttpParamSource,
50
+ HttpRouteDefinition,
51
+ HttpRouteGroupDefinition,
52
+ HttpSseDefinition,
53
+ HttpUploadDefinition,
54
+ } from './metadata.ts'
55
+ export type { PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute } from './plugin-routes.ts'
56
+ export type { TrustProxy } from './proxy.ts'
57
+ export type { HstsOptions, SecurityHeadersOptions } from './security-headers.ts'
58
+ export type { ByteSize } from './size.ts'
59
+ export type { SseBody, SseEvent, SseOptions, SseSource } from './sse.ts'
60
+ export type { ParseSseOptions, SseItem, SseMessage } from './sse-parse.ts'
61
+ export type { StreamSource } from './stream.ts'
62
+
63
+ export { type NodeAdapter, type NodeAdapterOptions, nodeAdapter } from './adapters/node.ts'
64
+ export { isJsonType, type MediaType, parseBody, parseMediaType } from './body.ts'
65
+ export { type Coercer, type Coercers, coerce } from './coerce.ts'
66
+ export { RequestAbortedError } from './context.ts'
67
+ export { parseCookieHeader, parseSetCookie, serializeClearCookie, serializeCookie } from './cookies.ts'
68
+ export {
69
+ All,
70
+ Body,
71
+ BodyLimit,
72
+ Controller,
73
+ type ControllerOptions,
74
+ Cookie,
75
+ Ctx,
76
+ createRouteDecorator,
77
+ Delete,
78
+ Get,
79
+ Head,
80
+ Header,
81
+ Headers,
82
+ HttpCode,
83
+ Options,
84
+ Param,
85
+ Patch,
86
+ Post,
87
+ Put,
88
+ Query,
89
+ Req,
90
+ Res,
91
+ type RouteDecorator,
92
+ type RouteDecoratorArgs,
93
+ type RouteDecoratorInfo,
94
+ type RouteDecoratorOptions,
95
+ type RouteMetaEntry,
96
+ SignedCookie,
97
+ Sse,
98
+ UploadedFile,
99
+ UploadedFiles,
100
+ UseGuard,
101
+ UseInterceptor,
102
+ Validate,
103
+ } from './decorators.ts'
104
+ export { EventChannel } from './event-channel.ts'
105
+ export {
106
+ BadGatewayException,
107
+ BadRequestException,
108
+ ConflictException,
109
+ ForbiddenException,
110
+ GatewayTimeoutException,
111
+ GoneException,
112
+ type HeaderValue,
113
+ type HttpErrorBody,
114
+ HttpException,
115
+ type HttpExceptionOptions,
116
+ InternalServerErrorException,
117
+ isHttpException,
118
+ MethodNotAllowedException,
119
+ NotFoundException,
120
+ NotImplementedException,
121
+ PayloadTooLargeException,
122
+ ServiceUnavailableException,
123
+ TooManyRequestsException,
124
+ toIssueDetail,
125
+ UnauthorizedException,
126
+ UnprocessableEntityException,
127
+ UnsupportedMediaTypeException,
128
+ ValidationException,
129
+ type ValidationIssueDetail,
130
+ } from './exceptions.ts'
131
+ export {
132
+ type ByteRange,
133
+ type ContentDispositionType,
134
+ contentDisposition,
135
+ type FileBody,
136
+ type FileResponseOptions,
137
+ parseRange,
138
+ } from './files.ts'
139
+ export { contentTypeFor, matchesMediaType } from './mime.ts'
140
+ export {
141
+ type ExceptionMapperRef,
142
+ type GuardRef,
143
+ type HttpCookieOptions,
144
+ HttpPlugin,
145
+ type HttpPluginOptions,
146
+ type HttpRouteSummary,
147
+ type HttpSseOptions,
148
+ type InterceptorRef,
149
+ type TrailingSlashPolicy,
150
+ } from './plugin.ts'
151
+ export { parseQuery, type QueryRecord } from './query.ts'
152
+ export {
153
+ HttpResponse,
154
+ type HttpResponseInit,
155
+ isHttpResponse,
156
+ type RedirectStatus,
157
+ type StreamResponseOptions,
158
+ } from './response.ts'
159
+ export { formatByteSize, parseByteSize } from './size.ts'
160
+ export { encodeSseEvent } from './sse.ts'
161
+ export { parseSseStream } from './sse-parse.ts'
162
+ export {
163
+ type InferSchemaInput,
164
+ type InferSchemaOutput,
165
+ isStandardSchema,
166
+ type StandardSchemaV1,
167
+ type StandardSchemaV1FailureResult,
168
+ type StandardSchemaV1Issue,
169
+ type StandardSchemaV1Options,
170
+ type StandardSchemaV1PathSegment,
171
+ type StandardSchemaV1Props,
172
+ type StandardSchemaV1Result,
173
+ type StandardSchemaV1SuccessResult,
174
+ type StandardSchemaV1Types,
175
+ } from './standard-schema.ts'
176
+ export { isValidStatus, statusText } from './status.ts'
177
+ export { HttpTokens } from './tokens.ts'
178
+ export {
179
+ type UploadedFileOptions,
180
+ type UploadedFilesOptions,
181
+ type UploadLimits,
182
+ type Uploads,
183
+ uploads,
184
+ } from './uploads.ts'
185
+ export { validate } from './validate.ts'
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `injectSse()`: an in-memory `EventSource` for tests, on top of `injectStream()`. Events are read
3
+ * only when asked for (`next()`, `take()`, iteration, `done`), so a test controls the pace of the
4
+ * client. Opening never waits forever ({@link OPEN_TIMEOUT_MS} per step): without response headers
5
+ * it fails (the request is aborted); without a first chunk (an `@Sse` stream sends `retry` or a
6
+ * comment at once, a hand-written or proxied one may stay quiet) the stream is open anyway, and the
7
+ * read goes on in the background.
8
+ */
9
+ import type { InjectStreamOptions, InjectStreamRequest, InjectStreamResponse } from './adapter.ts'
10
+ import type { SetCookieInfo } from './cookies.ts'
11
+
12
+ import { isEventStreamType } from './adapters/shared.ts'
13
+ import { parseWith, type SseItem, type SseMessage, SseParser } from './sse-parse.ts'
14
+
15
+ /** Options of {@link InjectSseResponse.next} and {@link InjectSseResponse.take}. */
16
+ export interface InjectSseWaitOptions {
17
+ /** Maximum wait for each event (ms). Default: 2 000. */
18
+ readonly timeoutMs?: number
19
+ }
20
+
21
+ /** The response of `injectSse()`: an event stream read in memory, like `EventSource` would. */
22
+ export interface InjectSseResponse extends AsyncIterable<SseMessage>, AsyncDisposable {
23
+ readonly status: number
24
+ readonly headers: Record<string, string | string[]>
25
+ readonly cookies: Readonly<Record<string, SetCookieInfo>>
26
+ /** `''` for an event stream; otherwise the whole body, read to its end (an error, a 204...). */
27
+ readonly body: string
28
+ /** `JSON.parse(body)`. */
29
+ json<T = unknown>(): T
30
+ /** The next event (comments skipped). Rejects on timeout (default 2 000 ms) or when the stream ended. */
31
+ next(options?: InjectSseWaitOptions): Promise<SseMessage>
32
+ /** The next `count` events (the timeout applies to each). */
33
+ take(count: number, options?: InjectSseWaitOptions): Promise<SseMessage[]>
34
+ /** Comments received so far (heartbeats are `''`). */
35
+ readonly comments: readonly string[]
36
+ /** What a browser would send as `Last-Event-ID` on reconnection. */
37
+ readonly lastEventId: string
38
+ /** The last `retry` received (ms). */
39
+ readonly retry: number | undefined
40
+ /** Resolves when the server ends the stream (reading the remaining events, which stay available to `next()`). */
41
+ readonly done: Promise<void>
42
+ /** Disconnects (cancels the body): the server sees a client disconnect. Idempotent. */
43
+ close(): Promise<void>
44
+ /** Reconnects like `EventSource`: closes this stream and sends the same request plus `last-event-id`. */
45
+ reconnect(): Promise<InjectSseResponse>
46
+ }
47
+
48
+ const DEFAULT_TIMEOUT_MS = 2_000
49
+ /** How long opening waits for the response headers, then for the first chunk of an event stream (ms). */
50
+ const OPEN_TIMEOUT_MS = 2_000
51
+ const DECODER = new TextDecoder()
52
+
53
+ type InjectStream = (request: InjectStreamOptions) => Promise<InjectStreamResponse>
54
+
55
+ /**
56
+ * Opens an event stream with `injectStream` (adds `accept: text/event-stream` when absent), waiting
57
+ * at most `openTimeoutMs` for the response headers, then at most `openTimeoutMs` for the first chunk.
58
+ */
59
+ export async function openInjectSse(
60
+ injectStream: InjectStream,
61
+ request: InjectStreamOptions,
62
+ openTimeoutMs: number = OPEN_TIMEOUT_MS,
63
+ ): Promise<InjectSseResponse> {
64
+ if (request === null || typeof request !== 'object') throw new TypeError('injectSse() expects a request object')
65
+ const headers: Record<string, string> = { ...request.headers }
66
+ if (!Object.keys(headers).some(name => name.toLowerCase() === 'accept')) headers['accept'] = 'text/event-stream'
67
+ const sent: InjectStreamRequest = { ...request, method: request.method ?? 'GET', headers }
68
+ const response = await answered(injectStream, sent, openTimeoutMs)
69
+ if (!isEventStreamType(response.headers['content-type'])) {
70
+ const body = await readText(response.body)
71
+ return new SseClient(injectStream, sent, response, body, openTimeoutMs)
72
+ }
73
+ const client = new SseClient(injectStream, sent, response, '', openTimeoutMs)
74
+ // the first chunk makes `retry` and `comments` known at once; a quiet stream must not hang the test
75
+ await client.ready()
76
+ return client
77
+ }
78
+
79
+ /**
80
+ * `injectStream(request)`, waiting at most `ms` for the response headers. Past it, the request is
81
+ * aborted (a late response is cancelled: the server sees a client disconnect) and opening fails
82
+ * with an error naming the request, instead of hanging the test.
83
+ */
84
+ async function answered(
85
+ injectStream: InjectStream,
86
+ request: InjectStreamRequest,
87
+ ms: number,
88
+ ): Promise<InjectStreamResponse> {
89
+ const abort = new AbortController()
90
+ const signal = request.signal === undefined ? abort.signal : AbortSignal.any([request.signal, abort.signal])
91
+ const pending = injectStream({ ...request, signal })
92
+ let timer: ReturnType<typeof setTimeout> | undefined
93
+ const timeout = new Promise<undefined>(resolve => {
94
+ timer = setTimeout(() => resolve(undefined), ms)
95
+ })
96
+ const response = await Promise.race([pending, timeout]).finally(() => clearTimeout(timer))
97
+ if (response !== undefined) return response
98
+ const message = `injectSse(): no response from ${describe(request)} within ${ms} ms`
99
+ abort.abort(new Error(message))
100
+ void pending.then(
101
+ late => late.body.cancel().catch(() => undefined),
102
+ () => undefined,
103
+ )
104
+ throw new Error(
105
+ `${message}: the handler has not answered, or the adapter holds the response headers until the first byte of the body`,
106
+ )
107
+ }
108
+
109
+ /** `GET /orders/o-1/events`. */
110
+ function describe(request: InjectStreamRequest): string {
111
+ return `${(request.method ?? 'GET').toUpperCase()} ${request.url}`
112
+ }
113
+
114
+ async function readText(body: ReadableStream<Uint8Array>): Promise<string> {
115
+ const reader = body.getReader()
116
+ let text = ''
117
+ for (;;) {
118
+ const { done, value } = await reader.read()
119
+ if (done) return text + DECODER.decode()
120
+ text += DECODER.decode(value, { stream: true })
121
+ }
122
+ }
123
+
124
+ class SseClient implements InjectSseResponse {
125
+ readonly status: number
126
+ readonly headers: Record<string, string | string[]>
127
+ readonly cookies: Readonly<Record<string, SetCookieInfo>>
128
+ readonly body: string
129
+ readonly #injectStream: InjectStream
130
+ readonly #request: InjectStreamRequest
131
+ readonly #openTimeoutMs: number
132
+ readonly #parser = new SseParser()
133
+ readonly #items: AsyncIterableIterator<SseItem> | undefined
134
+ readonly #events: SseMessage[] = []
135
+ readonly #comments: string[] = []
136
+ #retry: number | undefined
137
+ /** Whether an item (event, comment, `retry`) was read since the stream opened. */
138
+ #received = false
139
+ #ended: boolean
140
+ #failure: unknown
141
+ #reading: Promise<void> | undefined
142
+ #closing: Promise<void> | undefined
143
+ #draining: Promise<void> | undefined
144
+
145
+ constructor(
146
+ injectStream: InjectStream,
147
+ request: InjectStreamRequest,
148
+ response: InjectStreamResponse,
149
+ body: string,
150
+ openTimeoutMs: number,
151
+ ) {
152
+ this.#injectStream = injectStream
153
+ this.#request = request
154
+ this.#openTimeoutMs = openTimeoutMs
155
+ this.status = response.status
156
+ this.headers = response.headers
157
+ this.cookies = response.cookies
158
+ this.body = body
159
+ const stream = isEventStreamType(response.headers['content-type'])
160
+ this.#ended = !stream
161
+ this.#items = stream ? parseWith(this.#parser, response.body) : undefined
162
+ }
163
+
164
+ get comments(): readonly string[] {
165
+ return [...this.#comments]
166
+ }
167
+
168
+ get lastEventId(): string {
169
+ return this.#parser.lastEventId
170
+ }
171
+
172
+ get retry(): number | undefined {
173
+ return this.#retry
174
+ }
175
+
176
+ get done(): Promise<void> {
177
+ this.#draining ??= (async (): Promise<void> => {
178
+ while (!this.#ended) await this.#read()
179
+ })()
180
+ return this.#draining
181
+ }
182
+
183
+ json<T = unknown>(): T {
184
+ return JSON.parse(this.body) as T
185
+ }
186
+
187
+ /**
188
+ * Reads the first item (the preamble every `@Sse` stream starts with, so that `retry` is known),
189
+ * waiting at most the open timeout: the read of a quiet stream goes on in the background.
190
+ */
191
+ async ready(): Promise<void> {
192
+ await this.#readWithin(this.#openTimeoutMs)
193
+ }
194
+
195
+ async next(options: InjectSseWaitOptions = {}): Promise<SseMessage> {
196
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
197
+ if (!(timeoutMs >= 0)) throw new TypeError('injectSse(): timeoutMs must be a number ≥ 0')
198
+ const deadline = Date.now() + timeoutMs
199
+ for (;;) {
200
+ const event = this.#events.shift()
201
+ if (event !== undefined) return event
202
+ if (this.#ended) {
203
+ throw new Error(`injectSse(): the event stream of ${this.#describe()} ended`, {
204
+ ...(this.#failure === undefined ? {} : { cause: this.#failure }),
205
+ })
206
+ }
207
+ const remaining = deadline - Date.now()
208
+ if (remaining <= 0 || !(await this.#readWithin(remaining))) {
209
+ const quiet = this.#received ? '' : ' (the stream has sent nothing since it opened)'
210
+ throw new Error(`injectSse(): no event received within ${timeoutMs} ms from ${this.#describe()}${quiet}`)
211
+ }
212
+ }
213
+ }
214
+
215
+ async take(count: number, options?: InjectSseWaitOptions): Promise<SseMessage[]> {
216
+ if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('injectSse(): take() expects an integer ≥ 0')
217
+ const events: SseMessage[] = []
218
+ while (events.length < count) events.push(await this.next(options))
219
+ return events
220
+ }
221
+
222
+ close(): Promise<void> {
223
+ if (this.#closing === undefined) {
224
+ this.#ended = true
225
+ this.#closing =
226
+ this.#items === undefined
227
+ ? Promise.resolve()
228
+ : (this.#items.return?.().then(() => undefined) ?? Promise.resolve())
229
+ }
230
+ return this.#closing
231
+ }
232
+
233
+ async reconnect(): Promise<InjectSseResponse> {
234
+ await this.close()
235
+ const headers: Record<string, string> = {}
236
+ for (const [name, value] of Object.entries(this.#request.headers ?? {})) {
237
+ if (name.toLowerCase() !== 'last-event-id') headers[name] = value
238
+ }
239
+ // EventSource only sends a non-empty last event id
240
+ if (this.lastEventId !== '') headers['last-event-id'] = this.lastEventId
241
+ return openInjectSse(this.#injectStream, { ...this.#request, headers }, this.#openTimeoutMs)
242
+ }
243
+
244
+ [Symbol.asyncIterator](): AsyncIterator<SseMessage> {
245
+ return {
246
+ next: async (): Promise<IteratorResult<SseMessage>> => {
247
+ for (;;) {
248
+ const event = this.#events.shift()
249
+ if (event !== undefined) return { done: false, value: event }
250
+ if (this.#ended) return { done: true, value: undefined }
251
+ await this.#read()
252
+ }
253
+ },
254
+ return: async (): Promise<IteratorResult<SseMessage>> => {
255
+ await this.close()
256
+ return { done: true, value: undefined }
257
+ },
258
+ }
259
+ }
260
+
261
+ [Symbol.asyncDispose](): Promise<void> {
262
+ return this.close()
263
+ }
264
+
265
+ /** Waits for the next item at most `ms` (the read goes on in the background); `false` on timeout. */
266
+ async #readWithin(ms: number): Promise<boolean> {
267
+ let timer: ReturnType<typeof setTimeout> | undefined
268
+ const timeout = new Promise<false>(resolve => {
269
+ timer = setTimeout(() => resolve(false), ms)
270
+ })
271
+ const outcome = await Promise.race([this.#read().then(() => true as const), timeout])
272
+ clearTimeout(timer)
273
+ return outcome
274
+ }
275
+
276
+ /** Reads one item (one read at a time, shared by concurrent callers). */
277
+ #read(): Promise<void> {
278
+ this.#reading ??= this.#readItem().finally(() => {
279
+ this.#reading = undefined
280
+ })
281
+ return this.#reading
282
+ }
283
+
284
+ async #readItem(): Promise<void> {
285
+ const items = this.#items
286
+ if (items === undefined || this.#ended) return
287
+ let result: IteratorResult<SseItem>
288
+ try {
289
+ result = await items.next()
290
+ } catch (error) {
291
+ this.#failure = error
292
+ this.#ended = true
293
+ return
294
+ }
295
+ if (result.done === true) {
296
+ this.#ended = true
297
+ return
298
+ }
299
+ const item = result.value
300
+ this.#received = true
301
+ if (item.type === 'comment') this.#comments.push(item.text)
302
+ else if (item.type === 'retry') this.#retry = item.retry
303
+ else this.#events.push({ event: item.event, data: item.data, id: item.id, json: item.json })
304
+ }
305
+
306
+ #describe(): string {
307
+ return describe(this.#request)
308
+ }
309
+ }
@@ -0,0 +1,101 @@
1
+ import type { HttpContext } from './context.ts'
2
+ import type { RouteDecoratorInfo } from './decorators.ts'
3
+ import type { HttpException } from './exceptions.ts'
4
+ import type { HttpResponse } from './response.ts'
5
+
6
+ /**
7
+ * A guard decides whether a request may reach the handler. Guards run in order: global (plugin
8
+ * options) → controller (`@UseGuard` on the class) → route. Returning `false` responds
9
+ * `403 Forbidden`; throw an `HttpException` (e.g. `UnauthorizedException`) for another response.
10
+ *
11
+ * ```ts
12
+ * @Injectable()
13
+ * export class RolesGuard implements CanActivate {
14
+ * canActivate(ctx: HttpContext): boolean {
15
+ * const roles = Roles.get(ctx) // route metadata from createRouteDecorator
16
+ * return roles === undefined || roles.some(role => currentUser(ctx).roles.includes(role))
17
+ * }
18
+ * }
19
+ * ```
20
+ */
21
+ export interface CanActivate {
22
+ canActivate(ctx: HttpContext): boolean | Promise<boolean>
23
+ }
24
+
25
+ /** Runs the rest of the interceptor chain and the handler; resolves with the handler result. */
26
+ export type CallHandler = () => Promise<unknown>
27
+
28
+ /**
29
+ * Wraps the handler (after the guards). Interceptors run global → controller → route, the first one
30
+ * being the outermost. `next()` resolves with the handler result (before serialisation): return it,
31
+ * transform it, or return something else without calling `next()` (cache hit...).
32
+ *
33
+ * ```ts
34
+ * @Injectable()
35
+ * export class TimingInterceptor implements HttpInterceptor {
36
+ * async intercept(ctx: HttpContext, next: CallHandler) {
37
+ * const started = performance.now()
38
+ * try {
39
+ * return await next()
40
+ * } finally {
41
+ * ctx.setHeader('server-timing', `handler;dur=${performance.now() - started}`)
42
+ * }
43
+ * }
44
+ * }
45
+ * ```
46
+ */
47
+ export interface HttpInterceptor {
48
+ intercept(ctx: HttpContext, next: CallHandler): unknown
49
+ }
50
+
51
+ /** What an {@link ExceptionMapper} may return: `undefined` lets the next mapper (or the default) handle the error. */
52
+ export type ExceptionMapperResult = HttpException | HttpResponse | Response | undefined
53
+
54
+ /**
55
+ * Converts errors to responses. Mappers (plugin option `exceptionMappers`) are tried in order for
56
+ * every error of the pipeline, including `404`/`405`; the first one returning something wins. Return
57
+ * an `HttpException` to reuse the default error body, an `HttpResponse` or a Web `Response` for full
58
+ * control, `undefined` to pass.
59
+ *
60
+ * ```ts
61
+ * @Injectable()
62
+ * export class DomainErrorMapper implements ExceptionMapper {
63
+ * map(error: unknown) {
64
+ * if (error instanceof UserNotFoundError) return new NotFoundException(error.message)
65
+ * return undefined
66
+ * }
67
+ * }
68
+ * ```
69
+ */
70
+ export interface ExceptionMapper {
71
+ map(error: unknown, ctx: HttpContext): ExceptionMapperResult | Promise<ExceptionMapperResult>
72
+ }
73
+
74
+ /**
75
+ * Static side of a guard class: the route decorators whose decisions the guard enforces. Read by
76
+ * `HttpPlugin` at boot: a route whose decision belongs to a required group (or to a group some
77
+ * guard enforces) must have a guard enforcing that group in its chain (ZNIGHT023 otherwise).
78
+ *
79
+ * ```ts
80
+ * @Injectable()
81
+ * export class AuthGuard implements CanActivate {
82
+ * static readonly enforces = [Public, Authenticated, Roles]
83
+ * canActivate(ctx: HttpContext) { ... }
84
+ * }
85
+ * ```
86
+ *
87
+ * Typed with {@link RouteDecoratorInfo} (not `RouteDecorator<unknown>`) so that `[Public, Roles]`
88
+ * stays assignable under `strictFunctionTypes`.
89
+ */
90
+ export interface GuardEnforcement {
91
+ readonly enforces?: readonly RouteDecoratorInfo[]
92
+ }
93
+
94
+ /** A class producing {@link CanActivate} instances (for `@UseGuard` and plugin options). */
95
+ export type GuardClass = (abstract new (...args: never[]) => CanActivate) & GuardEnforcement
96
+
97
+ /** A class producing {@link HttpInterceptor} instances. */
98
+ export type HttpInterceptorClass = abstract new (...args: never[]) => HttpInterceptor
99
+
100
+ /** A class producing {@link ExceptionMapper} instances. */
101
+ export type ExceptionMapperClass = abstract new (...args: never[]) => ExceptionMapper