@owlmeans/socket 0.1.18-rc.8 → 0.1.18

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/src/model.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { ResilientError } from '@owlmeans/error'
2
2
  import { CALL_TIMEOUT, MessageType } from './consts.js'
3
- import { SocketMessageMalformed, SocketTimeout } from './errors.js'
3
+ import { SocketMessageMalformed, SocketTimeout, SocketUnauthorized, SocketUnsupported } from './errors.js'
4
4
  import type {
5
5
  AuthMessage, CallHendler, CallMessage, CallResolver, Connection, ConnectionListener,
6
6
  EventMessage, Message, RequestHandler
7
7
  } from './types.js'
8
- import { uuid } from '@owlmeans/basic-ids'
9
8
  import { AuthenticationStage, AuthError } from '@owlmeans/auth'
9
+ import { logger } from '@owlmeans/log'
10
+ import { idHelper } from '@owlmeans/basic-ids'
11
+
12
+ const log = logger('socket')
10
13
 
11
14
  export const createBasicConnection = (): Connection => {
12
15
  const listeners: ConnectionListener[] = []
@@ -52,7 +55,7 @@ export const createBasicConnection = (): Connection => {
52
55
 
53
56
  call: async (method, ...payload) => {
54
57
  const msg: CallMessage<any> = {
55
- method, payload, type: MessageType.Call, id: uuid()
58
+ method, payload, type: MessageType.Call, id: idHelper.uuid()
56
59
  }
57
60
  conn.prepare?.(msg)
58
61
  return new Promise(async (resolve, reject) => {
@@ -86,7 +89,7 @@ export const createBasicConnection = (): Connection => {
86
89
 
87
90
  request: async (payload, observer) => {
88
91
  const msg: Message<any> = {
89
- type: MessageType.Request, payload, id: uuid()
92
+ type: MessageType.Request, payload, id: idHelper.uuid()
90
93
  }
91
94
  if (observer != null) {
92
95
  observers[msg.id!] = observer
@@ -175,116 +178,148 @@ export const createBasicConnection = (): Connection => {
175
178
  },
176
179
 
177
180
  receive: async message => {
178
- if (message.startsWith('{') || message.startsWith('[')) {
179
- let msg: Message<any> | string = message
180
- try {
181
- msg = JSON.parse(message)
182
- } catch {
183
- }
184
- if (typeof msg === 'object') {
185
- if (msg.payload == null) {
186
- msg.payload = msg
187
- }
188
- if (msg.type == null) {
189
- msg.type = MessageType.Message
190
- }
191
- msg.rawData = message
192
- if (conn.prepare != null) {
193
- conn.prepare(msg, true)
194
- }
195
- try {
196
- switch (msg.type) {
197
- case MessageType.Call: {
198
- await conn._receiveCall(msg as CallMessage<any>)
199
- break
200
- }
201
- case MessageType.Result: {
202
- await conn._receiveResult(msg)
203
- break
204
- }
205
- case MessageType.Error: {
206
- await conn._receiveError(msg)
207
- break
208
- }
209
- case MessageType.Request: {
210
- await conn._receiveRequest(msg)
211
- break
212
- }
213
- case MessageType.Response: {
214
- await conn._receiveResponse(msg)
215
- break
216
- }
217
- case MessageType.Event: {
218
- await conn._receiveEvent(msg as EventMessage<any>)
219
- break
220
- }
221
- case MessageType.Message: {
222
- await conn._receiveMessage(msg)
223
- break
181
+ if (!message.startsWith('{') && !message.startsWith('[')) {
182
+ return
183
+ }
184
+
185
+ let parsed: unknown
186
+ try {
187
+ parsed = JSON.parse(message)
188
+ } catch {
189
+ throw new SocketMessageMalformed('json')
190
+ }
191
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
192
+ throw new SocketMessageMalformed('shape')
193
+ }
194
+
195
+ const msg = parsed as Message<any>
196
+ if (msg.payload == null) {
197
+ msg.payload = parsed
198
+ }
199
+ if (msg.type == null) {
200
+ msg.type = MessageType.Message
201
+ }
202
+ if (!Object.values(MessageType).includes(msg.type)) {
203
+ throw new SocketMessageMalformed('type')
204
+ }
205
+ // System frames belong to the carrier lifecycle and are never accepted from the wire.
206
+ if (msg.type === MessageType.System) {
207
+ throw new SocketMessageMalformed('system')
208
+ }
209
+ if (conn.requiresAuthentication === true
210
+ && conn.stage !== AuthenticationStage.Authenticated
211
+ && msg.type !== MessageType.Auth) {
212
+ throw new SocketUnauthorized('stage')
213
+ }
214
+
215
+ msg.rawData = message
216
+ conn.prepare?.(msg, true)
217
+
218
+ try {
219
+ switch (msg.type) {
220
+ case MessageType.Call:
221
+ await conn._receiveCall(msg as CallMessage<any>)
222
+ break
223
+ case MessageType.Result:
224
+ await conn._receiveResult(msg)
225
+ break
226
+ case MessageType.Error:
227
+ await conn._receiveError(msg)
228
+ break
229
+ case MessageType.Request:
230
+ await conn._receiveRequest(msg)
231
+ break
232
+ case MessageType.Response:
233
+ await conn._receiveResponse(msg)
234
+ break
235
+ case MessageType.Event:
236
+ await conn._receiveEvent(msg as EventMessage<any>)
237
+ break
238
+ case MessageType.Message:
239
+ await conn._receiveMessage(msg)
240
+ break
241
+ case MessageType.Auth: {
242
+ const authMessage = msg as AuthMessage<any>
243
+ if (conn._authSequence != null) {
244
+ if (authMessage.stage == null) {
245
+ conn._authSequence.reject(ResilientError.ensure(
246
+ authMessage.payload ?? new AuthError('socket:unknown')
247
+ ))
248
+ } else {
249
+ conn.stage = authMessage.stage
250
+ conn._authSequence.resolve(authMessage.payload)
224
251
  }
225
- case MessageType.Auth: {
226
- const _msg: AuthMessage<any> = msg as AuthMessage<any>
227
- if (conn._authSequence != null) {
228
- if (_msg.stage == null) {
229
- conn._authSequence.reject(ResilientError.ensure(_msg.payload ?? new AuthError('socket:unknown')))
230
- } else {
231
- conn.stage = _msg.stage
232
- conn._authSequence.resolve(_msg.payload)
233
- }
234
- conn._authSequence = undefined
235
- } else {
236
- try {
237
- const [stage, response] = await conn.authenticate(_msg.stage, _msg.payload)
238
- if (stage != null) {
239
- conn.auth(stage, response).finally(() => void 0)
240
- }
241
- } catch (e) {
242
- conn.auth(null as any, ResilientError.marshal(ResilientError.ensure(e as Error))).finally(() => void 0)
243
- } finally {
244
- conn._authSequence = undefined
245
- }
252
+ conn._authSequence = undefined
253
+ } else {
254
+ try {
255
+ const [stage, response] = await conn.authenticate(authMessage.stage, authMessage.payload)
256
+ if (stage != null) {
257
+ void conn.auth(stage, response).catch(error => {
258
+ log.warn('Socket authentication response not sent', { error })
259
+ })
246
260
  }
247
- break
261
+ } catch (error) {
262
+ void conn.auth(
263
+ null as any,
264
+ ResilientError.marshal(ResilientError.ensure(error as Error))
265
+ ).catch(sendError => {
266
+ log.warn('Socket authentication error not sent', { error: sendError })
267
+ })
268
+ } finally {
269
+ conn._authSequence = undefined
248
270
  }
249
271
  }
250
- } catch (e) {
251
- console.error('Error on message processing:', e)
252
- throw ResilientError.ensure(e as Error)
272
+ break
253
273
  }
254
274
  }
255
- await Promise.all(listeners.map(async listener => listener(msg)))
275
+ } catch (error) {
276
+ throw ResilientError.ensure(error as Error)
256
277
  }
278
+
279
+ // A consumer listener is an observation hook, not part of dispatch. One rejecting listener
280
+ // must neither skip its peers nor reject the transport event callback.
281
+ await Promise.all(listeners.map(async listener => {
282
+ try {
283
+ await listener(msg)
284
+ } catch (error) {
285
+ log.error('Socket listener failed', { error })
286
+ }
287
+ }))
257
288
  },
258
289
 
259
290
  _receiveCall: async msg => {
291
+ if (msg.id == null) {
292
+ throw new SocketMessageMalformed('call:id')
293
+ }
294
+ if (typeof msg.method !== 'string' || msg.method.length === 0 || !Array.isArray(msg.payload)) {
295
+ throw new SocketMessageMalformed('call')
296
+ }
297
+
260
298
  let cancel = false
261
- callPerformers[msg.method](...msg.payload)
262
- .then(async result => {
263
- if (cancel) {
264
- return
265
- }
266
- clearTimeout(timeout)
267
- const resultMsg: Message<any> = {
268
- id: msg.id,
269
- type: MessageType.Result,
270
- payload: result
271
- }
272
- await conn.send(resultMsg)
273
- }).catch(async error => {
274
- console.error('Error during the call:', error)
275
- if (cancel) {
276
- return
277
- }
278
- clearTimeout(timeout)
279
- const response: Message<any> = {
280
- id: msg.id,
281
- type: MessageType.Error,
282
- payload: ResilientError.ensure(error).marshal().message
283
- }
284
- await conn.send(response)
285
- })
286
299
  msg.timeout = msg.timeout ?? conn.defaultCallTimeout ?? CALL_TIMEOUT
287
300
  const timeout = setTimeout(() => { if (msg.timeout !== 0) cancel = true }, msg.timeout)
301
+
302
+ try {
303
+ const performer = callPerformers[msg.method]
304
+ if (performer == null) {
305
+ throw new SocketUnsupported(`call:${msg.method}`)
306
+ }
307
+ const result = await performer(...msg.payload)
308
+ if (!cancel) {
309
+ await conn.send({ id: msg.id, type: MessageType.Result, payload: result })
310
+ }
311
+ } catch (error) {
312
+ if (!cancel) {
313
+ const resilient = ResilientError.ensure(error as Error)
314
+ await conn.send({
315
+ id: msg.id,
316
+ type: MessageType.Error,
317
+ payload: resilient.marshal().message
318
+ })
319
+ }
320
+ } finally {
321
+ clearTimeout(timeout)
322
+ }
288
323
  },
289
324
 
290
325
  _receiveResult: async msg => {
package/src/types.ts CHANGED
@@ -9,6 +9,12 @@ export interface Connection {
9
9
 
10
10
  stage: AuthenticationStage
11
11
 
12
+ /**
13
+ * When enabled by a server carrier, only authentication frames are accepted until the
14
+ * connection reaches `AuthenticationStage.Authenticated`.
15
+ */
16
+ requiresAuthentication?: boolean
17
+
12
18
  notify: <T>(event: string, payload: T) => Promise<void>
13
19
  observe: <T>(event: string, handler: (event: EventMessage<T>) => Promise<void>) => () => void
14
20
 
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthenticationStage } from '@owlmeans/auth'
3
+ import { ResilientError } from '@owlmeans/error'
4
+ import {
5
+ createBasicConnection, MessageType, SocketMessageMalformed, SocketUnauthorized, SocketUnsupported
6
+ } from '../src/index.js'
7
+ import type { Message } from '../src/index.js'
8
+
9
+ describe('@owlmeans/socket — inbound frame boundary', () => {
10
+ test('rejects malformed JSON, non-object frames, and wire System frames', async () => {
11
+ const connection = createBasicConnection()
12
+
13
+ await expect(connection.receive('{')).rejects.toBeInstanceOf(SocketMessageMalformed)
14
+ await expect(connection.receive('[]')).rejects.toBeInstanceOf(SocketMessageMalformed)
15
+ await expect(connection.receive(JSON.stringify({
16
+ type: MessageType.System, event: 'close', payload: {}
17
+ }))).rejects.toBeInstanceOf(SocketMessageMalformed)
18
+ })
19
+
20
+ test('permits only authentication frames before a server carrier authenticates the connection', async () => {
21
+ const connection = createBasicConnection()
22
+ connection.requiresAuthentication = true
23
+
24
+ await expect(connection.receive(JSON.stringify({
25
+ type: MessageType.Message, payload: 'before-auth'
26
+ }))).rejects.toBeInstanceOf(SocketUnauthorized)
27
+
28
+ connection.stage = AuthenticationStage.Authenticated
29
+ await connection.receive(JSON.stringify({ type: MessageType.Message, payload: 'after-auth' }))
30
+ expect(connection.consume()).toEqual(['after-auth', undefined, 0])
31
+ })
32
+
33
+ test('answers an unknown call with a typed error instead of rejecting dispatch', async () => {
34
+ const connection = createBasicConnection()
35
+ const sent: Message<unknown>[] = []
36
+ connection.send = async message => {
37
+ if (typeof message !== 'string') sent.push(message)
38
+ }
39
+
40
+ await connection.receive(JSON.stringify({
41
+ type: MessageType.Call, id: 'call-1', method: 'missing', payload: []
42
+ }))
43
+
44
+ expect(sent).toHaveLength(1)
45
+ expect(sent[0]?.type).toBe(MessageType.Error)
46
+ expect(ResilientError.ensure(sent[0]?.payload as string)).toBeInstanceOf(SocketUnsupported)
47
+ })
48
+
49
+ test('isolates rejecting listeners and still runs their peers', async () => {
50
+ const connection = createBasicConnection()
51
+ let observed = false
52
+ connection.listen(async () => { throw new Error('listener failed') })
53
+ connection.listen(async () => { observed = true })
54
+
55
+ await connection.receive(JSON.stringify({ type: MessageType.Message, payload: 'event' }))
56
+
57
+ expect(observed).toBe(true)
58
+ })
59
+ })
package/tsconfig.json CHANGED
@@ -7,5 +7,5 @@
7
7
  "rootDir": "./src/",
8
8
  "outDir": "./build/"
9
9
  },
10
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
11
  }