@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/README.md +2 -2
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/socket/SKILL.md +115 -13
- package/build/consts.d.ts +21 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +22 -0
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js.map +1 -1
- package/build/helper/types.d.ts +13 -0
- package/build/helper/types.d.ts.map +1 -0
- package/build/helper/types.js +2 -0
- package/build/helper/types.js.map +1 -0
- package/build/helper.d.ts +6 -3
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +14 -6
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +130 -105
- package/build/model.js.map +1 -1
- package/build/types.d.ts +5 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/consts.ts +23 -0
- package/src/helper/types.ts +13 -0
- package/src/helper.ts +27 -15
- package/src/index.ts +1 -0
- package/src/model.ts +136 -101
- package/src/types.ts +6 -0
- package/tests/model.spec.ts +59 -0
- package/tsconfig.json +1 -1
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('{')
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
console.error('Error on message processing:', e)
|
|
252
|
-
throw ResilientError.ensure(e as Error)
|
|
272
|
+
break
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
|
-
|
|
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