@znight/websocket 0.0.0 → 0.6.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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/dist/bun.d.mts +4 -0
- package/dist/bun.mjs +112 -0
- package/dist/bun.mjs.map +1 -0
- package/dist/compiler.d.mts +90 -0
- package/dist/compiler.mjs +864 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +501 -0
- package/dist/index.mjs +2088 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-CI_Bh9o1.d.mts +331 -0
- package/dist/server-D40_bC88.mjs +72 -0
- package/dist/server-D40_bC88.mjs.map +1 -0
- package/dist/testing.d.mts +60 -0
- package/dist/testing.mjs +189 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/transport-xnJqvB93.d.mts +63 -0
- package/dist/ws.d.mts +9 -0
- package/dist/ws.mjs +143 -0
- package/dist/ws.mjs.map +1 -0
- package/package.json +102 -1
- package/src/client.ts +246 -0
- package/src/compiler/analyze.ts +850 -0
- package/src/compiler/constants.ts +102 -0
- package/src/compiler/data.ts +78 -0
- package/src/compiler/generate.ts +262 -0
- package/src/compiler/index.ts +53 -0
- package/src/compiler/validate.ts +81 -0
- package/src/connection.ts +455 -0
- package/src/context.ts +43 -0
- package/src/decorators.ts +83 -0
- package/src/dispatcher.ts +343 -0
- package/src/envelope.ts +136 -0
- package/src/exceptions.ts +91 -0
- package/src/index.ts +89 -0
- package/src/metadata.ts +74 -0
- package/src/options.ts +343 -0
- package/src/plugin.ts +820 -0
- package/src/rate-limit.ts +27 -0
- package/src/rooms.ts +185 -0
- package/src/server.ts +125 -0
- package/src/testing/index.ts +279 -0
- package/src/tokens.ts +23 -0
- package/src/transport.ts +72 -0
- package/src/transports/bun.ts +163 -0
- package/src/transports/memory.ts +174 -0
- package/src/transports/ws.ts +237 -0
- package/src/validate.ts +31 -0
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** `metadata.extensions.websocket` (RFC 0003 §7.1): what the compiler generates for the gateways. */
|
|
2
|
+
import type { Token } from '@znight/core'
|
|
3
|
+
import type { WsConnection } from './connection.ts'
|
|
4
|
+
import type { WsMessageContext } from './context.ts'
|
|
5
|
+
|
|
6
|
+
/** The gateways of the application, sorted by token. */
|
|
7
|
+
export interface WebSocketMetadata {
|
|
8
|
+
readonly gateways: readonly GatewayDefinition[]
|
|
9
|
+
/**
|
|
10
|
+
* Route decorator groups the runtime needs (required, holding several keys, or named differently
|
|
11
|
+
* from their key), as `metadata.extensions.http.groups`. Absent when no group needs it.
|
|
12
|
+
*/
|
|
13
|
+
readonly groups?: Readonly<Record<string, { readonly keys: readonly string[]; readonly required: boolean }>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One `@Gateway` class. Optional fields are omitted when absent. */
|
|
17
|
+
export interface GatewayDefinition {
|
|
18
|
+
readonly token: Token
|
|
19
|
+
/** The normalized `@Gateway` path, without the `HttpPlugin` prefix. */
|
|
20
|
+
readonly path: string
|
|
21
|
+
readonly protocol: 'events' | 'raw'
|
|
22
|
+
readonly protocols?: readonly string[]
|
|
23
|
+
/** Bytes. */
|
|
24
|
+
readonly maxPayload?: number
|
|
25
|
+
readonly concurrency?: number
|
|
26
|
+
readonly rateLimit?: { readonly messages: number; readonly intervalMs: number; readonly onLimit: 'reject' | 'close' }
|
|
27
|
+
/** `limit` in bytes. */
|
|
28
|
+
readonly backpressure?: { readonly limit?: number; readonly onLimit?: 'close' | 'drop' }
|
|
29
|
+
/** Route decorators of the class: handshake and messages. */
|
|
30
|
+
readonly meta: Readonly<Record<string, unknown>>
|
|
31
|
+
/** `@UseHandshakeGuard`, in order. */
|
|
32
|
+
readonly handshakeGuards: readonly Token[]
|
|
33
|
+
/** `@UseMessageGuard` on the class, in order. */
|
|
34
|
+
readonly messageGuards: readonly Token[]
|
|
35
|
+
readonly onConnect?: WsLifecycleDefinition
|
|
36
|
+
readonly onDisconnect?: WsLifecycleDefinition
|
|
37
|
+
/** Declaration order, bases first. */
|
|
38
|
+
readonly handlers: readonly WsHandlerDefinition[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `@OnConnect` or `@OnDisconnect`. */
|
|
42
|
+
export interface WsLifecycleDefinition {
|
|
43
|
+
readonly method: string
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the gateway instance of their own class
|
|
45
|
+
readonly invoke: (gateway: any, connection: WsConnection) => unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A parameter of a handler (informational). */
|
|
49
|
+
export interface WsParamDefinition {
|
|
50
|
+
readonly index: number
|
|
51
|
+
readonly source: 'payload' | 'connection' | 'context'
|
|
52
|
+
readonly schema?: boolean
|
|
53
|
+
readonly optional?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One `@OnMessage` handler. */
|
|
57
|
+
export interface WsHandlerDefinition {
|
|
58
|
+
readonly method: string
|
|
59
|
+
/** Absent: the default handler. */
|
|
60
|
+
readonly event?: string
|
|
61
|
+
/** Route decorators of the method, merged over the gateway's at boot (nearest wins). */
|
|
62
|
+
readonly meta: Readonly<Record<string, unknown>>
|
|
63
|
+
/** `@UseMessageGuard` on the method. */
|
|
64
|
+
readonly guards: readonly Token[]
|
|
65
|
+
readonly params: readonly WsParamDefinition[]
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the gateway instance of their own class
|
|
67
|
+
readonly invoke: (gateway: any, ctx: WsMessageContext) => unknown
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
declare module '@znight/core' {
|
|
71
|
+
interface ZnightExtensionsMetadata {
|
|
72
|
+
readonly websocket?: WebSocketMetadata
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/** Options of `@Gateway` and of `WebSocketPlugin` (RFC 0003 §5.2, §5.9). */
|
|
2
|
+
import type { ByteSize, HttpContext } from '@znight/http'
|
|
3
|
+
import type { WsExceptionMapper, WsGuard } from './context.ts'
|
|
4
|
+
import type { WsCodec } from './envelope.ts'
|
|
5
|
+
import type { WebSocketTransport } from './transport.ts'
|
|
6
|
+
|
|
7
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
8
|
+
import { parseByteSize } from '@znight/http'
|
|
9
|
+
|
|
10
|
+
import { jsonCodec } from './envelope.ts'
|
|
11
|
+
|
|
12
|
+
/** Options of `@Gateway` (RFC 0003 §5.2), all static: the compiler reads them. */
|
|
13
|
+
export interface GatewayOptions {
|
|
14
|
+
/** HTTP path grammar (`/chat`, `/docs/:docId`, `/feeds/*`). Default: `'/'`. */
|
|
15
|
+
readonly path?: string
|
|
16
|
+
/** `'request'`: one gateway instance per connection. Default: `'singleton'`. */
|
|
17
|
+
readonly scope?: 'singleton' | 'request'
|
|
18
|
+
/** `'events'` (envelope, routing by event) or `'raw'` (one default handler). Default: `'events'`. */
|
|
19
|
+
readonly protocol?: 'events' | 'raw'
|
|
20
|
+
/** Accepted subprotocols, in preference order: a client offering none of them is refused (400). */
|
|
21
|
+
readonly protocols?: readonly string[]
|
|
22
|
+
/** Largest inbound message (`'64kb'` or bytes). Default: the plugin `maxPayload`. */
|
|
23
|
+
readonly maxPayload?: string | number
|
|
24
|
+
/** Messages of one connection handled in parallel. Default: `1` (keeps the order). */
|
|
25
|
+
readonly concurrency?: number
|
|
26
|
+
/** A token bucket per connection. */
|
|
27
|
+
readonly rateLimit?: RateLimitOptions
|
|
28
|
+
/** Outbound buffer limit and what to do above it. Default: the plugin `backpressure`. */
|
|
29
|
+
readonly backpressure?: BackpressureOptions
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `@Gateway({ rateLimit })`. */
|
|
33
|
+
export interface RateLimitOptions {
|
|
34
|
+
readonly messages: number
|
|
35
|
+
readonly intervalMs: number
|
|
36
|
+
/** `'reject'` answers `RATE_LIMITED` (default), `'close'` closes with `4429`. */
|
|
37
|
+
readonly onLimit?: 'reject' | 'close'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `@Gateway({ backpressure })` and the plugin option. */
|
|
41
|
+
export interface BackpressureOptions {
|
|
42
|
+
/** Buffered outbound bytes above which `onLimit` applies (`'1mb'` or bytes). */
|
|
43
|
+
readonly limit?: string | number
|
|
44
|
+
/** `'close'` closes with `1013` (default), `'drop'` drops the message. */
|
|
45
|
+
readonly onLimit?: 'close' | 'drop'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// -------------------------------------------------------------------------------------------------
|
|
49
|
+
// WebSocketPlugin options (RFC 0003 §5.9)
|
|
50
|
+
// -------------------------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** `'same-origin'`, `'*'`, exact origins and anchored `RegExp`s, or a function (RFC 0003 §5.6). */
|
|
53
|
+
export type WsOrigins =
|
|
54
|
+
| 'same-origin'
|
|
55
|
+
| '*'
|
|
56
|
+
| string
|
|
57
|
+
| RegExp
|
|
58
|
+
| readonly (string | RegExp)[]
|
|
59
|
+
| ((origin: string, ctx: HttpContext) => boolean | Promise<boolean>)
|
|
60
|
+
|
|
61
|
+
/** A message guard or an exception mapper: a provider class or an instance. */
|
|
62
|
+
export type WsProviderRef<T> = (abstract new (...args: never[]) => T) | T
|
|
63
|
+
|
|
64
|
+
/** Options of `WebSocketPlugin.configure()`. */
|
|
65
|
+
export interface WebSocketPluginOptions {
|
|
66
|
+
/** `'auto'` (default): Bun's native transport with `honoAdapter()` on Bun, `ws` otherwise. */
|
|
67
|
+
readonly transport?: 'auto' | WebSocketTransport
|
|
68
|
+
/** Allowed `Origin`s of handshakes. Default: `'same-origin'` (and requests without `Origin`). */
|
|
69
|
+
readonly origins?: WsOrigins
|
|
70
|
+
/** Largest inbound message (`'1mb'` or bytes). Default: `'1mb'`. Gateways may lower it. */
|
|
71
|
+
readonly maxPayload?: ByteSize
|
|
72
|
+
/** Open connections per process; above, handshakes get `503`. Default: `Infinity`. */
|
|
73
|
+
readonly maxConnections?: number
|
|
74
|
+
/** Inbound queue per connection before reading pauses. Default: `32`. */
|
|
75
|
+
readonly maxPendingMessages?: number
|
|
76
|
+
/** Outbound buffer per connection. Default: `{ limit: '1mb', onLimit: 'close' }`. */
|
|
77
|
+
readonly backpressure?: BackpressureOptions
|
|
78
|
+
/** Ping interval; `false` disables heartbeats. Default: `{ intervalMs: 30_000 }`. */
|
|
79
|
+
readonly heartbeat?: { readonly intervalMs: number } | false
|
|
80
|
+
/** Handshakes whose guards have not answered by then get `408`. Default: `10_000`. */
|
|
81
|
+
readonly handshakeTimeoutMs?: number
|
|
82
|
+
/** Per message and per `@OnConnect`: `TIMEOUT`. `Infinity` disables. Default: `30_000`. */
|
|
83
|
+
readonly handlerTimeoutMs?: number
|
|
84
|
+
/** Wait for the client's close frame before terminating. Default: `2_000`. */
|
|
85
|
+
readonly closeTimeoutMs?: number
|
|
86
|
+
/** The codec of `events` gateways. Default: `jsonCodec` (JSON text frames). */
|
|
87
|
+
readonly codec?: WsCodec
|
|
88
|
+
/** Global message guards, run before the gateway's and the handler's. */
|
|
89
|
+
readonly messageGuards?: readonly WsProviderRef<WsGuard>[]
|
|
90
|
+
/** Exception mappers of message handlers and guards, in order. */
|
|
91
|
+
readonly exceptionMappers?: readonly WsProviderRef<WsExceptionMapper>[]
|
|
92
|
+
/** Send the message of unexpected errors (`INTERNAL`). Default: `false`. */
|
|
93
|
+
readonly exposeErrors?: boolean
|
|
94
|
+
/** Graceful shutdown. Default: `{ code: 1001, reason: 'Server shutting down', timeoutMs: 3_000 }`. */
|
|
95
|
+
readonly shutdown?: { readonly code?: number; readonly reason?: string; readonly timeoutMs?: number }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Validated plugin options (internal). */
|
|
99
|
+
export interface ResolvedWebSocketOptions {
|
|
100
|
+
readonly transport: 'auto' | WebSocketTransport
|
|
101
|
+
readonly origins: OriginPolicy
|
|
102
|
+
readonly maxPayload: number
|
|
103
|
+
readonly maxConnections: number
|
|
104
|
+
readonly maxPendingMessages: number
|
|
105
|
+
readonly backpressure: { readonly limit: number; readonly onLimit: 'close' | 'drop' }
|
|
106
|
+
readonly heartbeatMs: number
|
|
107
|
+
readonly handshakeTimeoutMs: number
|
|
108
|
+
readonly handlerTimeoutMs: number
|
|
109
|
+
readonly closeTimeoutMs: number
|
|
110
|
+
readonly codec: WsCodec
|
|
111
|
+
readonly messageGuards: readonly unknown[]
|
|
112
|
+
readonly exceptionMappers: readonly unknown[]
|
|
113
|
+
readonly exposeErrors: boolean
|
|
114
|
+
readonly shutdown: { readonly code: number; readonly reason: string; readonly timeoutMs: number }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How origins are checked (internal). */
|
|
118
|
+
export interface OriginPolicy {
|
|
119
|
+
/** `'same-origin'`: the request's own origin only. */
|
|
120
|
+
readonly sameOrigin: boolean
|
|
121
|
+
allows(origin: string, ctx: HttpContext): boolean | Promise<boolean>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const OPTION_KEYS: ReadonlySet<string> = new Set([
|
|
125
|
+
'transport',
|
|
126
|
+
'origins',
|
|
127
|
+
'maxPayload',
|
|
128
|
+
'maxConnections',
|
|
129
|
+
'maxPendingMessages',
|
|
130
|
+
'backpressure',
|
|
131
|
+
'heartbeat',
|
|
132
|
+
'handshakeTimeoutMs',
|
|
133
|
+
'handlerTimeoutMs',
|
|
134
|
+
'closeTimeoutMs',
|
|
135
|
+
'codec',
|
|
136
|
+
'messageGuards',
|
|
137
|
+
'exceptionMappers',
|
|
138
|
+
'exposeErrors',
|
|
139
|
+
'shutdown',
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
/** `ZNIGHT050: WebSocketPlugin: option "<name>" must be <expected> (received <value>)`. */
|
|
143
|
+
export function invalidOption(name: string, expected: string, value: unknown, hints?: readonly string[]): ZnightError {
|
|
144
|
+
return new ZnightError(
|
|
145
|
+
ErrorCode.InvalidGateway,
|
|
146
|
+
`WebSocketPlugin: option "${name}" must be ${expected} (received ${describe(value)})`,
|
|
147
|
+
hints === undefined ? {} : { hints },
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function describe(value: unknown): string {
|
|
152
|
+
if (typeof value === 'function') return 'a function'
|
|
153
|
+
if (value instanceof RegExp) return String(value)
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(value) ?? String(value)
|
|
156
|
+
} catch {
|
|
157
|
+
return typeof value
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function positiveInteger(name: string, value: unknown, fallback: number, infinite = false): number {
|
|
162
|
+
if (value === undefined) return fallback
|
|
163
|
+
if (infinite && value === Number.POSITIVE_INFINITY) return value
|
|
164
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
165
|
+
throw invalidOption(name, `a positive integer${infinite ? ' or Infinity' : ''}`, value)
|
|
166
|
+
}
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function byteSize(name: string, value: unknown, fallback: number): number {
|
|
171
|
+
if (value === undefined) return fallback
|
|
172
|
+
const bytes = parseByteSize(value)
|
|
173
|
+
if (bytes === undefined || bytes === 0 || bytes === Number.POSITIVE_INFINITY) {
|
|
174
|
+
throw invalidOption(name, "a positive byte size ('64kb', 65536)", value)
|
|
175
|
+
}
|
|
176
|
+
return bytes
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Validates the options of `WebSocketPlugin.configure()` (ZNIGHT050). */
|
|
180
|
+
export function resolveWebSocketOptions(options: unknown): ResolvedWebSocketOptions {
|
|
181
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
182
|
+
throw invalidOption('options', 'an object', options)
|
|
183
|
+
}
|
|
184
|
+
for (const key of Object.keys(options)) {
|
|
185
|
+
if (!OPTION_KEYS.has(key)) {
|
|
186
|
+
throw new ZnightError(ErrorCode.InvalidGateway, `WebSocketPlugin: unknown option "${key}"`, {
|
|
187
|
+
hints: [`Known options: ${[...OPTION_KEYS].join(', ')}`],
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const o = options as WebSocketPluginOptions
|
|
192
|
+
const transport = o.transport ?? 'auto'
|
|
193
|
+
if (
|
|
194
|
+
transport !== 'auto' &&
|
|
195
|
+
(transport === null || typeof transport !== 'object' || typeof transport.accept !== 'function')
|
|
196
|
+
) {
|
|
197
|
+
throw invalidOption('transport', "'auto', wsTransport() or bunTransport()", transport)
|
|
198
|
+
}
|
|
199
|
+
const backpressure = o.backpressure ?? {}
|
|
200
|
+
if (backpressure === null || typeof backpressure !== 'object') {
|
|
201
|
+
throw invalidOption('backpressure', "{ limit?, onLimit?: 'close' | 'drop' }", backpressure)
|
|
202
|
+
}
|
|
203
|
+
const onLimit = backpressure.onLimit ?? 'close'
|
|
204
|
+
if (onLimit !== 'close' && onLimit !== 'drop')
|
|
205
|
+
throw invalidOption('backpressure.onLimit', "'close' or 'drop'", onLimit)
|
|
206
|
+
const heartbeat = o.heartbeat ?? { intervalMs: 30_000 }
|
|
207
|
+
let heartbeatMs = 0
|
|
208
|
+
if (heartbeat !== false) {
|
|
209
|
+
if (heartbeat === null || typeof heartbeat !== 'object') {
|
|
210
|
+
throw invalidOption('heartbeat', '{ intervalMs } or false', heartbeat)
|
|
211
|
+
}
|
|
212
|
+
heartbeatMs = positiveInteger('heartbeat.intervalMs', heartbeat.intervalMs, 30_000)
|
|
213
|
+
}
|
|
214
|
+
const shutdown = o.shutdown ?? {}
|
|
215
|
+
if (shutdown === null || typeof shutdown !== 'object') {
|
|
216
|
+
throw invalidOption('shutdown', '{ code?, reason?, timeoutMs? }', shutdown)
|
|
217
|
+
}
|
|
218
|
+
const code = shutdown.code ?? 1001
|
|
219
|
+
if (!isCloseCode(code)) throw invalidOption('shutdown.code', 'a close code (1000, 1001, 1012, 3000-4999)', code)
|
|
220
|
+
const reason = shutdown.reason ?? 'Server shutting down'
|
|
221
|
+
if (typeof reason !== 'string' || Buffer.byteLength(reason) > 123) {
|
|
222
|
+
throw invalidOption('shutdown.reason', 'a string of at most 123 bytes', reason)
|
|
223
|
+
}
|
|
224
|
+
const codec = o.codec ?? jsonCodec
|
|
225
|
+
if (
|
|
226
|
+
codec === null ||
|
|
227
|
+
typeof codec !== 'object' ||
|
|
228
|
+
typeof codec.decode !== 'function' ||
|
|
229
|
+
typeof codec.encode !== 'function'
|
|
230
|
+
) {
|
|
231
|
+
throw invalidOption('codec', '{ decode(frame, binary), encode(message) }', codec)
|
|
232
|
+
}
|
|
233
|
+
if (o.exposeErrors !== undefined && typeof o.exposeErrors !== 'boolean') {
|
|
234
|
+
throw invalidOption('exposeErrors', 'a boolean', o.exposeErrors)
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
transport,
|
|
238
|
+
origins: resolveOrigins(o.origins ?? 'same-origin'),
|
|
239
|
+
maxPayload: byteSize('maxPayload', o.maxPayload, 1024 * 1024),
|
|
240
|
+
maxConnections: positiveInteger('maxConnections', o.maxConnections, Number.POSITIVE_INFINITY, true),
|
|
241
|
+
maxPendingMessages: positiveInteger('maxPendingMessages', o.maxPendingMessages, 32),
|
|
242
|
+
backpressure: Object.freeze({ limit: byteSize('backpressure.limit', backpressure.limit, 1024 * 1024), onLimit }),
|
|
243
|
+
heartbeatMs,
|
|
244
|
+
handshakeTimeoutMs: positiveInteger('handshakeTimeoutMs', o.handshakeTimeoutMs, 10_000, true),
|
|
245
|
+
handlerTimeoutMs: positiveInteger('handlerTimeoutMs', o.handlerTimeoutMs, 30_000, true),
|
|
246
|
+
closeTimeoutMs: positiveInteger('closeTimeoutMs', o.closeTimeoutMs, 2_000),
|
|
247
|
+
codec,
|
|
248
|
+
messageGuards: refs('messageGuards', o.messageGuards, 'canHandle'),
|
|
249
|
+
exceptionMappers: refs('exceptionMappers', o.exceptionMappers, 'map'),
|
|
250
|
+
exposeErrors: o.exposeErrors ?? false,
|
|
251
|
+
shutdown: Object.freeze({
|
|
252
|
+
code,
|
|
253
|
+
reason,
|
|
254
|
+
timeoutMs: positiveInteger('shutdown.timeoutMs', shutdown.timeoutMs, 3_000),
|
|
255
|
+
}),
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether a close code may be sent by a server (RFC 6455 §7.4). */
|
|
260
|
+
export function isCloseCode(code: unknown): code is number {
|
|
261
|
+
if (typeof code !== 'number' || !Number.isInteger(code)) return false
|
|
262
|
+
if (code >= 3000 && code <= 4999) return true
|
|
263
|
+
return code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function refs(name: string, value: unknown, method: string): readonly unknown[] {
|
|
267
|
+
if (value === undefined) return []
|
|
268
|
+
if (!Array.isArray(value)) throw invalidOption(name, `an array of classes or instances with ${method}()`, value)
|
|
269
|
+
for (const ref of value as unknown[]) {
|
|
270
|
+
const ok =
|
|
271
|
+
typeof ref === 'function'
|
|
272
|
+
? typeof (ref.prototype as Record<string, unknown> | undefined)?.[method] === 'function'
|
|
273
|
+
: ref !== null && typeof ref === 'object' && typeof (ref as Record<string, unknown>)[method] === 'function'
|
|
274
|
+
if (!ok) throw invalidOption(name, `an array of classes or instances with ${method}()`, ref)
|
|
275
|
+
}
|
|
276
|
+
return Object.freeze([...(value as unknown[])])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Validates the `origins` option (RFC 0003 §5.6), with the rules of the `cors.origin` option of `HttpPlugin`. */
|
|
280
|
+
export function resolveOrigins(value: unknown): OriginPolicy {
|
|
281
|
+
if (value === 'same-origin') {
|
|
282
|
+
return { sameOrigin: true, allows: (origin, ctx) => origin === ownOrigin(ctx) }
|
|
283
|
+
}
|
|
284
|
+
if (typeof value === 'function') {
|
|
285
|
+
return { sameOrigin: false, allows: value as OriginPolicy['allows'] }
|
|
286
|
+
}
|
|
287
|
+
const items: readonly unknown[] = Array.isArray(value) ? value : [value]
|
|
288
|
+
const exact = new Set<string>()
|
|
289
|
+
const patterns: RegExp[] = []
|
|
290
|
+
for (const item of items) {
|
|
291
|
+
if (item === '*') return { sameOrigin: false, allows: () => true }
|
|
292
|
+
if (typeof item === 'string') exact.add(checkOrigin(item))
|
|
293
|
+
else if (item instanceof RegExp) patterns.push(checkPattern(item))
|
|
294
|
+
else throw invalidOption('origins', ORIGINS, value)
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
sameOrigin: false,
|
|
298
|
+
allows: origin => exact.has(origin) || patterns.some(pattern => pattern.test(origin)),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const ORIGINS = "'same-origin', '*', an origin, an anchored RegExp, a list of them or a function"
|
|
303
|
+
|
|
304
|
+
/** The origin of the request itself (`trustProxy` applies to `ctx.protocol`). */
|
|
305
|
+
function ownOrigin(ctx: HttpContext): string {
|
|
306
|
+
return `${ctx.protocol}://${ctx.header('host') ?? ''}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function checkOrigin(value: string): string {
|
|
310
|
+
if (value === 'null') {
|
|
311
|
+
throw invalidOption('origins', 'a real origin', value, [
|
|
312
|
+
'The "null" origin is shared by sandboxed iframes, local files and redirects: never trust it',
|
|
313
|
+
])
|
|
314
|
+
}
|
|
315
|
+
let url: URL | undefined
|
|
316
|
+
try {
|
|
317
|
+
url = new URL(value)
|
|
318
|
+
} catch {
|
|
319
|
+
url = undefined
|
|
320
|
+
}
|
|
321
|
+
if (url === undefined || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
|
|
322
|
+
throw invalidOption('origins', 'an http(s) origin such as "https://app.example.com"', value)
|
|
323
|
+
}
|
|
324
|
+
if (url.origin !== value) {
|
|
325
|
+
throw invalidOption('origins', `an origin without path, trailing slash or default port ("${url.origin}")`, value)
|
|
326
|
+
}
|
|
327
|
+
return value
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function checkPattern(pattern: RegExp): RegExp {
|
|
331
|
+
const source = pattern.source
|
|
332
|
+
const trailingBackslashes = /\\*\$$/.exec(source)?.[0].length ?? 0
|
|
333
|
+
const anchoredEnd = source.endsWith('$') && (trailingBackslashes - 1) % 2 === 0
|
|
334
|
+
if (!source.startsWith('^') || !anchoredEnd || /(^|[^\\])\|/.test(source.replace(/\([^)]*\)|\[[^\]]*\]/g, ''))) {
|
|
335
|
+
throw invalidOption('origins', 'a RegExp anchored with ^ and $ (around every alternative)', pattern, [
|
|
336
|
+
'Without anchors, /example\\.com/ also allows https://example.com.evil.net',
|
|
337
|
+
])
|
|
338
|
+
}
|
|
339
|
+
if (pattern.global || pattern.sticky) {
|
|
340
|
+
throw invalidOption('origins', 'a RegExp without the g or y flag (test() would be stateful)', pattern)
|
|
341
|
+
}
|
|
342
|
+
return pattern
|
|
343
|
+
}
|