@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/dist/bun.d.mts +4 -0
  4. package/dist/bun.mjs +112 -0
  5. package/dist/bun.mjs.map +1 -0
  6. package/dist/compiler.d.mts +90 -0
  7. package/dist/compiler.mjs +864 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/index.d.mts +501 -0
  10. package/dist/index.mjs +2088 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/dist/server-CI_Bh9o1.d.mts +331 -0
  13. package/dist/server-D40_bC88.mjs +72 -0
  14. package/dist/server-D40_bC88.mjs.map +1 -0
  15. package/dist/testing.d.mts +60 -0
  16. package/dist/testing.mjs +189 -0
  17. package/dist/testing.mjs.map +1 -0
  18. package/dist/transport-xnJqvB93.d.mts +63 -0
  19. package/dist/ws.d.mts +9 -0
  20. package/dist/ws.mjs +143 -0
  21. package/dist/ws.mjs.map +1 -0
  22. package/package.json +102 -1
  23. package/src/client.ts +246 -0
  24. package/src/compiler/analyze.ts +850 -0
  25. package/src/compiler/constants.ts +102 -0
  26. package/src/compiler/data.ts +78 -0
  27. package/src/compiler/generate.ts +262 -0
  28. package/src/compiler/index.ts +53 -0
  29. package/src/compiler/validate.ts +81 -0
  30. package/src/connection.ts +455 -0
  31. package/src/context.ts +43 -0
  32. package/src/decorators.ts +83 -0
  33. package/src/dispatcher.ts +343 -0
  34. package/src/envelope.ts +136 -0
  35. package/src/exceptions.ts +91 -0
  36. package/src/index.ts +89 -0
  37. package/src/metadata.ts +74 -0
  38. package/src/options.ts +343 -0
  39. package/src/plugin.ts +820 -0
  40. package/src/rate-limit.ts +27 -0
  41. package/src/rooms.ts +185 -0
  42. package/src/server.ts +125 -0
  43. package/src/testing/index.ts +279 -0
  44. package/src/tokens.ts +23 -0
  45. package/src/transport.ts +72 -0
  46. package/src/transports/bun.ts +163 -0
  47. package/src/transports/memory.ts +174 -0
  48. package/src/transports/ws.ts +237 -0
  49. package/src/validate.ts +31 -0
@@ -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
+ }