@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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- 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
|