@owlmeans/server-socket 0.1.18-rc.55 → 0.1.18-rc.56
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 +2 -2
- package/agent-meta/skills/server-socket/SKILL.md +38 -2
- package/build/consts.local.d.ts +3 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +3 -0
- package/build/consts.local.js.map +1 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +20 -2
- package/build/service.js.map +1 -1
- package/package.json +13 -9
- package/src/consts.local.ts +3 -0
- package/src/service.ts +22 -2
- package/tests/connection.spec.ts +99 -0
- package/tests/context.ts +248 -0
- package/tests/socket.spec.ts +247 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ in a queue (`@owlmeans/server-job`).
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
bun add @owlmeans/server-socket@^0.1.18-rc.
|
|
14
|
+
bun add @owlmeans/server-socket@^0.1.18-rc.56
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Concepts
|
|
@@ -210,7 +210,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
210
210
|
your project's skill store (`.agents/skills/`):
|
|
211
211
|
|
|
212
212
|
```sh
|
|
213
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
213
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.52
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-socket",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-10-
|
|
4
|
+
"version": "0.1.18-rc.56",
|
|
5
|
+
"generatedAt": "2026-10-08T21:27:54.312Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -6,7 +6,7 @@ description: Bind OwlMeans socket protocol declarations to Fastify WebSocket han
|
|
|
6
6
|
|
|
7
7
|
# Socket protocol handlers
|
|
8
8
|
|
|
9
|
-
**Install:** `bun add @owlmeans/server-socket@^0.1.18-rc.
|
|
9
|
+
**Install:** `bun add @owlmeans/server-socket@^0.1.18-rc.56`
|
|
10
10
|
|
|
11
11
|
Declare a socket route and its contract in the shared protocol package. Bind it on the server with
|
|
12
12
|
`connection()` and `bind()`.
|
|
@@ -25,5 +25,41 @@ export const serverBindings = [
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
The handler receives a typed connection, context, request and response boundary. Guards and gates
|
|
28
|
-
are inherited from the protocol tree and enforced before the WebSocket handler runs
|
|
28
|
+
are inherited from the protocol tree and enforced before the WebSocket handler runs: a refused
|
|
29
|
+
guard or a closed gate answers the upgrade with an HTTP error and the handler is never reached. A
|
|
30
|
+
guard that admits the upgrade authenticates the connection, so frames flow without an in-band
|
|
31
|
+
`authenticate`; an unguarded route accepts only authentication frames until its handler's
|
|
32
|
+
`authenticate` succeeds, and any other frame before that closes the socket with 1008. Do not expose
|
|
29
33
|
Fastify request objects through a shared contract.
|
|
34
|
+
|
|
35
|
+
## What the route does with the handler's answer
|
|
36
|
+
|
|
37
|
+
| The handler | The socket |
|
|
38
|
+
|---|---|
|
|
39
|
+
| returns after wiring listeners (what every `connection()` handler does) | stays open; **a connection handler that resolves nothing sends nothing** — no frame, empty or otherwise |
|
|
40
|
+
| resolves a value with `EntrypointOutcome.Ok` (`handleConnection`'s `res`) | receives that value once (a string as is, anything else as JSON), then is closed |
|
|
41
|
+
| throws | is closed with 1011; the reason is the refusal marshalled WITHOUT its stack (`type|||message`), clipped to the 123-byte close-reason limit |
|
|
42
|
+
|
|
43
|
+
`connection()` resolves `undefined` after its callback returns and rejects with whatever it threw.
|
|
44
|
+
Sending that `undefined` used to put an empty frame on the wire: Bun refuses it ("send requires a
|
|
45
|
+
non-empty message"), the throw became a rejection and every real socket closed with 1011 before it
|
|
46
|
+
could authenticate. A server stack never travels in a close reason.
|
|
47
|
+
|
|
48
|
+
The connection's own `listen` listeners receive the system `close` frame when either side closes,
|
|
49
|
+
which is where a handler releases what it subscribed to. Closing the API server closes every open
|
|
50
|
+
socket with 1001.
|
|
51
|
+
|
|
52
|
+
## Tests
|
|
53
|
+
|
|
54
|
+
`bun test ./tests` (category A, offline). `tests/context.ts` boots a real server context the way
|
|
55
|
+
`@owlmeans/server-app` does — `appendApiServer`, `appendSocketService`, `createSocketMiddleware()` —
|
|
56
|
+
on `port: 0`, reading the bound port back; a test guard and gate stand in for real ones. Its client
|
|
57
|
+
is a plain `WebSocket` under `createBasicConnection()`, the shape of viable's agent→publisher link.
|
|
58
|
+
|
|
59
|
+
| Spec | Covers |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `socket.spec.ts` | the idle handler staying open (the empty-frame regression); the publisher file watch (authenticate → `authenticated` → `{ event, path }` notifications; a refused authentication; a pre-auth frame → 1008); a throwing handler → 1011 and its stackless, clipped reason; a value with Ok → one frame and close; the thinking relay's unsubscribe on client close; guards and gates refusing before the handler; server shutdown → 1001 |
|
|
62
|
+
| `connection.spec.ts` | `connection()` against the real transport response: resolve with `undefined`, reject with the thrown error, a request without a socket, a binding without a context |
|
|
63
|
+
|
|
64
|
+
A change to the route's resolve/reject handling is verified by reverting it and watching the
|
|
65
|
+
regression spec fail.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.local.d.ts","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AACA,2FAA2F;AAC3F,eAAO,MAAM,kBAAkB,MAAM,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.local.js","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AACA,2FAA2F;AAC3F,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA"}
|
package/build/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAW,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAW,MAAM,sBAAsB,CAAA;AA+BpE,eAAO,MAAM,mBAAmB,WAAW,MAAM,KAAmB,aAoHnE,CAAA;AAED,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OACnE,CAAC,UAAS,MAAM,KACpB,CAOF,CAAA"}
|
package/build/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertContext, createService } from '@owlmeans/context';
|
|
2
2
|
import { DEFAULT_ALIAS } from './consts.js';
|
|
3
|
+
import { CLOSE_REASON_LIMIT } from './consts.local.js';
|
|
3
4
|
import { canServerModule } from './utils/server.js';
|
|
4
5
|
import { fastifyWebsocket } from '@fastify/websocket';
|
|
5
6
|
import { authorize, httpErrorHelper, makeRequestContextHelper, payloadHelper } from '@owlmeans/server-api/utils';
|
|
@@ -7,6 +8,18 @@ import { EntrypointOutcome, provideResponse } from '@owlmeans/entrypoint';
|
|
|
7
8
|
import { ResilientError } from '@owlmeans/error';
|
|
8
9
|
import { logger, logThrottle } from '@owlmeans/log';
|
|
9
10
|
const log = logger('server-socket');
|
|
11
|
+
/**
|
|
12
|
+
* A close reason is at most 123 UTF-8 bytes (RFC 6455): longer is cut by one transport and thrown
|
|
13
|
+
* on by another, so the marshalled refusal is clipped on a character boundary to fit.
|
|
14
|
+
*/
|
|
15
|
+
const closeReason = (reason) => {
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
let clipped = reason;
|
|
18
|
+
while (encoder.encode(clipped).length > CLOSE_REASON_LIMIT) {
|
|
19
|
+
clipped = clipped.slice(0, -1);
|
|
20
|
+
}
|
|
21
|
+
return clipped;
|
|
22
|
+
};
|
|
10
23
|
export const createSocketService = (alias = DEFAULT_ALIAS) => {
|
|
11
24
|
const service = createService(alias, {
|
|
12
25
|
update: async (api) => {
|
|
@@ -73,7 +86,12 @@ export const createSocketService = (alias = DEFAULT_ALIAS) => {
|
|
|
73
86
|
conn.on('error', (error) => log.error('WebSocket error', { route: module.alias, error }));
|
|
74
87
|
void module.handle(request, {
|
|
75
88
|
resolve: (value, outcome) => {
|
|
76
|
-
|
|
89
|
+
// A connection handler that only wires listeners resolves with nothing. There is no
|
|
90
|
+
// message to send then, and an empty frame is not one: the transport refuses it, and
|
|
91
|
+
// the refusal would close a socket that is just getting started.
|
|
92
|
+
if (value !== undefined && value !== null) {
|
|
93
|
+
conn.send(typeof value === 'string' ? value : JSON.stringify(value));
|
|
94
|
+
}
|
|
77
95
|
if (outcome === EntrypointOutcome.Ok) {
|
|
78
96
|
conn.close();
|
|
79
97
|
}
|
|
@@ -85,7 +103,7 @@ export const createSocketService = (alias = DEFAULT_ALIAS) => {
|
|
|
85
103
|
if (logThrottle(`socket.refused:${module.alias}:${refusal.type}`, 60_000)) {
|
|
86
104
|
log.warn('Connection rejected', details, { event: 'socket.refused' });
|
|
87
105
|
}
|
|
88
|
-
conn.close(1011, refusal.marshal().message);
|
|
106
|
+
conn.close(1011, closeReason(refusal.marshal({ includeStack: false }).message));
|
|
89
107
|
}
|
|
90
108
|
});
|
|
91
109
|
const close = () => conn.close(1001);
|
package/build/service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAGhE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/server-socket",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.56",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b",
|
|
8
8
|
"dev": "sleep 324 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
-
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
10
11
|
},
|
|
11
12
|
"main": "build/index.js",
|
|
12
13
|
"module": "build/index.js",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@owlmeans/dep-config": "workspace:*",
|
|
26
|
+
"@types/bun": "^1.4.2",
|
|
25
27
|
"@types/connect": "^3.4.38",
|
|
26
28
|
"nodemon": "^3.1.14",
|
|
27
29
|
"typescript": "^7.0.2"
|
|
@@ -31,16 +33,18 @@
|
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@fastify/websocket": "^11.3.3",
|
|
34
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
35
|
-
"@owlmeans/basic-envelope": "^0.1.18-rc.
|
|
36
|
+
"@owlmeans/auth": "^0.1.18-rc.41",
|
|
37
|
+
"@owlmeans/basic-envelope": "^0.1.18-rc.48",
|
|
36
38
|
"@owlmeans/context": "^0.1.18-rc.34",
|
|
37
39
|
"@owlmeans/entrypoint": "^0.1.18-rc.41",
|
|
38
|
-
"@owlmeans/
|
|
39
|
-
"@owlmeans/
|
|
40
|
-
"@owlmeans/
|
|
41
|
-
"@owlmeans/server-
|
|
40
|
+
"@owlmeans/error": "^0.1.18-rc.38",
|
|
41
|
+
"@owlmeans/log": "^0.1.18-rc.3",
|
|
42
|
+
"@owlmeans/route": "^0.1.18-rc.35",
|
|
43
|
+
"@owlmeans/server-api": "^0.1.18-rc.53",
|
|
44
|
+
"@owlmeans/server-auth": "^0.1.18-rc.56",
|
|
45
|
+
"@owlmeans/server-context": "^0.1.18-rc.44",
|
|
42
46
|
"@owlmeans/server-entrypoint": "^0.1.18-rc.40",
|
|
43
|
-
"@owlmeans/socket": "^0.1.18-rc.
|
|
47
|
+
"@owlmeans/socket": "^0.1.18-rc.42"
|
|
44
48
|
},
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
package/src/service.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { assertContext, createService } from '@owlmeans/context'
|
|
|
2
2
|
import type { SocketService } from './types.js'
|
|
3
3
|
import type { Config, Context, Request } from '@owlmeans/server-api'
|
|
4
4
|
import { DEFAULT_ALIAS } from './consts.js'
|
|
5
|
+
import { CLOSE_REASON_LIMIT } from './consts.local.js'
|
|
5
6
|
import type { FixerService, ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
6
7
|
import { canServerModule } from './utils/server.js'
|
|
7
8
|
import { fastifyWebsocket } from '@fastify/websocket'
|
|
@@ -16,6 +17,20 @@ import { logger, logThrottle } from '@owlmeans/log'
|
|
|
16
17
|
|
|
17
18
|
const log = logger('server-socket')
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A close reason is at most 123 UTF-8 bytes (RFC 6455): longer is cut by one transport and thrown
|
|
22
|
+
* on by another, so the marshalled refusal is clipped on a character boundary to fit.
|
|
23
|
+
*/
|
|
24
|
+
const closeReason = (reason: string): string => {
|
|
25
|
+
const encoder = new TextEncoder()
|
|
26
|
+
let clipped = reason
|
|
27
|
+
while (encoder.encode(clipped).length > CLOSE_REASON_LIMIT) {
|
|
28
|
+
clipped = clipped.slice(0, -1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return clipped
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export const createSocketService = (alias: string = DEFAULT_ALIAS): SocketService => {
|
|
20
35
|
const service: SocketService = createService<SocketService>(alias, {
|
|
21
36
|
update: async api => {
|
|
@@ -94,7 +109,12 @@ export const createSocketService = (alias: string = DEFAULT_ALIAS): SocketServic
|
|
|
94
109
|
|
|
95
110
|
void module.handle<AbstractRequest<WebSocket>>(request, {
|
|
96
111
|
resolve: (value, outcome) => {
|
|
97
|
-
|
|
112
|
+
// A connection handler that only wires listeners resolves with nothing. There is no
|
|
113
|
+
// message to send then, and an empty frame is not one: the transport refuses it, and
|
|
114
|
+
// the refusal would close a socket that is just getting started.
|
|
115
|
+
if (value !== undefined && value !== null) {
|
|
116
|
+
conn.send(typeof value === 'string' ? value : JSON.stringify(value))
|
|
117
|
+
}
|
|
98
118
|
if (outcome === EntrypointOutcome.Ok) {
|
|
99
119
|
conn.close()
|
|
100
120
|
}
|
|
@@ -106,7 +126,7 @@ export const createSocketService = (alias: string = DEFAULT_ALIAS): SocketServic
|
|
|
106
126
|
if (logThrottle(`socket.refused:${module.alias}:${refusal.type}`, 60_000)) {
|
|
107
127
|
log.warn('Connection rejected', details, { event: 'socket.refused' })
|
|
108
128
|
}
|
|
109
|
-
conn.close(1011, refusal.marshal().message)
|
|
129
|
+
conn.close(1011, closeReason(refusal.marshal({ includeStack: false }).message))
|
|
110
130
|
}
|
|
111
131
|
})
|
|
112
132
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AuthorizationError } from '@owlmeans/auth'
|
|
3
|
+
import type { AbstractResponse } from '@owlmeans/entrypoint'
|
|
4
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
5
|
+
import type { BoundEntrypointHandler, RefedEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
6
|
+
import { SocketInitializationError } from '@owlmeans/socket'
|
|
7
|
+
import type { Connection } from '@owlmeans/socket'
|
|
8
|
+
import { connection } from '../src/helper.js'
|
|
9
|
+
import { connectClient, protocols, roundTrip, startSocketServer, until } from './context.js'
|
|
10
|
+
import type { SocketServer } from './context.js'
|
|
11
|
+
|
|
12
|
+
type Call = ['resolve', unknown, unknown] | ['reject', Error]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs a `connection()` binding with the transport's own response and records what the binding
|
|
16
|
+
* told it — the response is the real one, so the socket behaves exactly as it does in production.
|
|
17
|
+
*/
|
|
18
|
+
const recorded = (binding: BoundEntrypointHandler<any>, calls: Call[]): RefedEntrypointHandler => ref => {
|
|
19
|
+
const handle = binding.bind(ref)
|
|
20
|
+
|
|
21
|
+
return async (request, response) => await handle(request, {
|
|
22
|
+
resolve: (value: unknown, outcome?: unknown) => {
|
|
23
|
+
calls.push(['resolve', value, outcome])
|
|
24
|
+
response.resolve(value, outcome as any)
|
|
25
|
+
},
|
|
26
|
+
reject: (error: Error) => {
|
|
27
|
+
calls.push(['reject', error])
|
|
28
|
+
response.reject(error)
|
|
29
|
+
},
|
|
30
|
+
} as AbstractResponse<any>)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const watchCalls: Call[] = []
|
|
34
|
+
const failingCalls: Call[] = []
|
|
35
|
+
const wired: Connection[] = []
|
|
36
|
+
const refusal = new AuthorizationError('slot')
|
|
37
|
+
|
|
38
|
+
const files = connection(protocols.files, async conn => {
|
|
39
|
+
conn.listen(async () => undefined)
|
|
40
|
+
wired.push(conn)
|
|
41
|
+
})
|
|
42
|
+
const failing = connection(protocols.failing, async () => { throw refusal })
|
|
43
|
+
|
|
44
|
+
describe('@owlmeans/server-socket — connection()', () => {
|
|
45
|
+
let server: SocketServer
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
server = await startSocketServer([
|
|
49
|
+
bind(protocols.files, recorded(files, watchCalls)),
|
|
50
|
+
bind(protocols.failing, recorded(failing, failingCalls)),
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
afterAll(async () => { await server?.close() })
|
|
54
|
+
|
|
55
|
+
test('publisher file watch: the handler only wires listeners, and is resolved with undefined once it returns', async () => {
|
|
56
|
+
const client = connectClient(server.url('/update/files'))
|
|
57
|
+
|
|
58
|
+
expect(await client.opened).toBe(true)
|
|
59
|
+
expect(await roundTrip(client)).toBe(true)
|
|
60
|
+
expect(wired).toHaveLength(1)
|
|
61
|
+
expect(watchCalls).toEqual([['resolve', undefined, undefined]])
|
|
62
|
+
client.socket.close()
|
|
63
|
+
await client.closed
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a handler that throws is answered through reject with its own error, never resolve', async () => {
|
|
67
|
+
const client = connectClient(server.url('/failing'))
|
|
68
|
+
|
|
69
|
+
await client.closed
|
|
70
|
+
await until(() => failingCalls.length > 0)
|
|
71
|
+
expect(failingCalls).toEqual([['reject', refusal]])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a request that carries no socket is rejected with SocketInitializationError before the handler', async () => {
|
|
75
|
+
let reached = false
|
|
76
|
+
const calls: Call[] = []
|
|
77
|
+
const binding = connection(protocols.idle, async () => { reached = true })
|
|
78
|
+
|
|
79
|
+
await recorded(binding, calls)({ ref: { ctx: server.context } } as any)(
|
|
80
|
+
{ alias: protocols.idle.alias, params: {}, query: {}, headers: {} } as any,
|
|
81
|
+
{ resolve: () => undefined, reject: () => undefined } as any,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(reached).toBe(false)
|
|
85
|
+
expect(calls).toHaveLength(1)
|
|
86
|
+
expect(calls[0]?.[0]).toBe('reject')
|
|
87
|
+
expect(calls[0]?.[1]).toBeInstanceOf(SocketInitializationError)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a binding reached without a context throws, naming the protocol, and never runs the handler', async () => {
|
|
91
|
+
let reached = false
|
|
92
|
+
const binding = connection(protocols.idle, async () => { reached = true })
|
|
93
|
+
|
|
94
|
+
expect(binding.protocol).toBe(protocols.idle)
|
|
95
|
+
await expect(binding.bind({ ref: {} } as any)({ alias: protocols.idle.alias } as any, {} as any))
|
|
96
|
+
.rejects.toThrow(protocols.idle.alias)
|
|
97
|
+
expect(reached).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { AppType, createLazyService, createService } from '@owlmeans/context'
|
|
3
|
+
import type { AbstractRequest, GateService, GuardService } from '@owlmeans/entrypoint'
|
|
4
|
+
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
5
|
+
import { openProtocol } from '@owlmeans/entrypoint'
|
|
6
|
+
import { AuthorizationError, AuthenticationStage } from '@owlmeans/auth'
|
|
7
|
+
import type { Auth } from '@owlmeans/auth'
|
|
8
|
+
import { route, socket } from '@owlmeans/route'
|
|
9
|
+
import { appendApiServer } from '@owlmeans/server-api'
|
|
10
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
11
|
+
import type { ServerConfig } from '@owlmeans/server-context'
|
|
12
|
+
import { createBasicConnection, MessageType } from '@owlmeans/socket'
|
|
13
|
+
import type { AuthenticateMethod, Connection, EventMessage } from '@owlmeans/socket'
|
|
14
|
+
import { appendSocketService } from '../src/service.js'
|
|
15
|
+
import { createSocketMiddleware } from '../src/middleware.js'
|
|
16
|
+
import type { Context } from '../src/types.js'
|
|
17
|
+
|
|
18
|
+
export const TEST_SERVICE = 'server-socket-tests'
|
|
19
|
+
export const TEST_GUARD = 'server-socket-test-guard'
|
|
20
|
+
export const TEST_GATE = 'server-socket-test-gate'
|
|
21
|
+
|
|
22
|
+
/** What the agent signs with and the publisher accepts — the in-band credential of every case. */
|
|
23
|
+
export const AGENT_TOKEN = 'signed-by-the-agent'
|
|
24
|
+
/** The query token the HTTP guard of a guarded socket route accepts. */
|
|
25
|
+
export const GUARD_TOKEN = 'guard-accepts-this'
|
|
26
|
+
|
|
27
|
+
/** The publisher's `API_PUBLISHER_WATCH` event: one `{ event, path }` per file-system change. */
|
|
28
|
+
export const WATCH_EVENT = 'server-socket-test:watch'
|
|
29
|
+
/** The manager-api's `agent:thinking` relay event. */
|
|
30
|
+
export const THINKING_EVENT = 'server-socket-test:thinking'
|
|
31
|
+
|
|
32
|
+
export interface WatchChange { event: string, path: string }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Socket routes declared the way a shared protocol package declares them — `socket()` routes in
|
|
36
|
+
* immutable protocol declarations — one per case the specs depict.
|
|
37
|
+
*/
|
|
38
|
+
export const protocols = {
|
|
39
|
+
/** The publisher's file watch (`publisherProtocols.update.files`). */
|
|
40
|
+
files: openProtocol(route('server-socket-test:update:files', '/update/files', socket())),
|
|
41
|
+
/** The manager-api's thinking relay (`back.update.project`). */
|
|
42
|
+
thinking: openProtocol(route('server-socket-test:update:thinking', '/update/thinking', socket())),
|
|
43
|
+
/** A handler that only wires listeners and resolves nothing. */
|
|
44
|
+
idle: openProtocol(route('server-socket-test:idle', '/idle', socket())),
|
|
45
|
+
/** A handler that throws before it is done. */
|
|
46
|
+
failing: openProtocol(route('server-socket-test:failing', '/failing', socket())),
|
|
47
|
+
/** A handler that answers once with a value and the Ok outcome. */
|
|
48
|
+
once: openProtocol(route('server-socket-test:once', '/once', socket())),
|
|
49
|
+
/** A route behind an HTTP guard. */
|
|
50
|
+
guarded: openProtocol(route('server-socket-test:guarded', '/guarded', socket()), { guards: TEST_GUARD }),
|
|
51
|
+
/** A route behind a gate. */
|
|
52
|
+
gated: openProtocol(route('server-socket-test:gated', '/gated', socket()), {
|
|
53
|
+
gate: { alias: TEST_GATE, params: ['watch'] },
|
|
54
|
+
}),
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
const queryOf = (request: AbstractRequest): Record<string, unknown> =>
|
|
58
|
+
(request.query ?? {}) as Record<string, unknown>
|
|
59
|
+
|
|
60
|
+
/** A guard that admits the upgrade carrying `GUARD_TOKEN` in its query, the way a bearer guard would. */
|
|
61
|
+
const createTestGuard = (): GuardService => createService<GuardService>(TEST_GUARD, {
|
|
62
|
+
authenticated: async () => null,
|
|
63
|
+
match: async request => queryOf(request).token != null,
|
|
64
|
+
handle: async (request, response) => {
|
|
65
|
+
if (queryOf(request).token !== GUARD_TOKEN) {
|
|
66
|
+
return false as any
|
|
67
|
+
}
|
|
68
|
+
response.resolve({ token: GUARD_TOKEN, userId: 'agent' } as Auth)
|
|
69
|
+
|
|
70
|
+
return true as any
|
|
71
|
+
},
|
|
72
|
+
}, service => async () => { service.initialized = true })
|
|
73
|
+
|
|
74
|
+
/** A gate that opens only for an upgrade asking for it, and records every assertion it made. */
|
|
75
|
+
export interface TestGate extends GateService {
|
|
76
|
+
asserted: string[][]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const createTestGate = (): TestGate => {
|
|
80
|
+
const asserted: string[][] = []
|
|
81
|
+
|
|
82
|
+
return createLazyService<TestGate>(TEST_GATE, {
|
|
83
|
+
asserted,
|
|
84
|
+
assert: async (request, _response, params) => {
|
|
85
|
+
asserted.push(params)
|
|
86
|
+
if (queryOf(request).gate !== 'open') {
|
|
87
|
+
throw new AuthorizationError('gate')
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SocketServer {
|
|
94
|
+
context: Context
|
|
95
|
+
gate: TestGate
|
|
96
|
+
/** The `ws://` address of a route path on the bound ephemeral port. */
|
|
97
|
+
url: (path: string, query?: Record<string, string>) => string
|
|
98
|
+
close: () => Promise<void>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A real server context wired the way `@owlmeans/server-app` wires one — the API server, the
|
|
103
|
+
* socket service and its middleware — listening on an ephemeral port (`port: 0`).
|
|
104
|
+
*/
|
|
105
|
+
export const startSocketServer = async (entrypoints: ServerEntrypoint<any>[]): Promise<SocketServer> => {
|
|
106
|
+
const context = makeServerContext(config<ServerConfig>(TEST_SERVICE, {
|
|
107
|
+
services: { [TEST_SERVICE]: { service: TEST_SERVICE, type: AppType.Backend, host: '127.0.0.1', port: 0 } },
|
|
108
|
+
} as Partial<ServerConfig>)) as unknown as Context
|
|
109
|
+
appendApiServer(context)
|
|
110
|
+
appendSocketService(context)
|
|
111
|
+
context.registerMiddleware(createSocketMiddleware())
|
|
112
|
+
context.registerService(createTestGuard())
|
|
113
|
+
const gate = createTestGate()
|
|
114
|
+
context.registerService(gate)
|
|
115
|
+
context.registerEntrypoints(entrypoints)
|
|
116
|
+
|
|
117
|
+
context.configure()
|
|
118
|
+
await context.init()
|
|
119
|
+
await context.getApiServer().listen()
|
|
120
|
+
|
|
121
|
+
const address = context.getApiServer().server.server.address()
|
|
122
|
+
const port = typeof address === 'object' && address != null ? address.port : 0
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
context,
|
|
126
|
+
gate,
|
|
127
|
+
url: (path, query) => `ws://127.0.0.1:${port}${path}${query != null ? `?${new URLSearchParams(query)}` : ''}`,
|
|
128
|
+
close: async () => { await context.getApiServer().server.close() },
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface Closed { code: number, reason: string }
|
|
133
|
+
|
|
134
|
+
export interface TestClient {
|
|
135
|
+
connection: Connection
|
|
136
|
+
socket: WebSocket
|
|
137
|
+
/** Every raw frame the server sent, in order. */
|
|
138
|
+
frames: string[]
|
|
139
|
+
/** True once the socket opened; false when it closed (or failed) before opening. */
|
|
140
|
+
opened: Promise<boolean>
|
|
141
|
+
closed: Promise<Closed>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The agent's side of the agent→publisher link (`connectWsWithSignedAuth` in viable's
|
|
146
|
+
* `sources/backend/src/lib/ws.ts`): a plain WebSocket under the shared connection model, whose
|
|
147
|
+
* close is reported to listeners as the system `close` frame.
|
|
148
|
+
*/
|
|
149
|
+
export const connectClient = (url: string): TestClient => {
|
|
150
|
+
const ws = new WebSocket(url)
|
|
151
|
+
const frames: string[] = []
|
|
152
|
+
const model = createBasicConnection()
|
|
153
|
+
|
|
154
|
+
model.send = async message => {
|
|
155
|
+
if (typeof message !== 'string') {
|
|
156
|
+
model.prepare?.(message)
|
|
157
|
+
}
|
|
158
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
159
|
+
ws.send(typeof message === 'string' ? message : JSON.stringify(message))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
model.close = async () => {
|
|
163
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
164
|
+
ws.close()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// The agent never answers a server-initiated exchange, exactly like the platform's client.
|
|
168
|
+
model.authenticate = (async () => []) as unknown as AuthenticateMethod
|
|
169
|
+
model.prepare = message => {
|
|
170
|
+
message.dt = message.dt ?? Date.now()
|
|
171
|
+
return message
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ws.addEventListener('message', event => {
|
|
175
|
+
const data = typeof event.data === 'string' ? event.data : String(event.data)
|
|
176
|
+
frames.push(data)
|
|
177
|
+
void model.receive(data).catch(() => undefined)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const opened = new Promise<boolean>(resolve => {
|
|
181
|
+
ws.addEventListener('open', () => resolve(true))
|
|
182
|
+
ws.addEventListener('close', () => resolve(false))
|
|
183
|
+
})
|
|
184
|
+
const closed = new Promise<Closed>(resolve => {
|
|
185
|
+
ws.addEventListener('close', event => {
|
|
186
|
+
const msg: EventMessage<{ code: number }> = {
|
|
187
|
+
type: MessageType.System, event: 'close', payload: { code: event.code },
|
|
188
|
+
}
|
|
189
|
+
void Promise.all(model.getListeners().map(async listener => await listener(msg)))
|
|
190
|
+
resolve({ code: event.code, reason: event.reason })
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return { connection: model, socket: ws, frames, opened, closed }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Send a heartbeat and wait for the server's `pong` — proof the socket is alive end to end. */
|
|
198
|
+
export const roundTrip = async (client: TestClient): Promise<boolean> => {
|
|
199
|
+
const before = client.frames.length
|
|
200
|
+
client.socket.send(JSON.stringify({ type: 'ping' }))
|
|
201
|
+
|
|
202
|
+
return await Promise.race([
|
|
203
|
+
new Promise<boolean>(resolve => {
|
|
204
|
+
const check = (): void => {
|
|
205
|
+
if (client.frames.slice(before).includes(JSON.stringify({ type: 'pong' }))) {
|
|
206
|
+
resolve(true)
|
|
207
|
+
} else {
|
|
208
|
+
setTimeout(check, 5)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
check()
|
|
212
|
+
}),
|
|
213
|
+
client.closed.then(() => false),
|
|
214
|
+
])
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Wait until `predicate` holds, or fail after `timeout` ms. */
|
|
218
|
+
export const until = async (predicate: () => boolean, timeout: number = 2_000): Promise<void> => {
|
|
219
|
+
const started = Date.now()
|
|
220
|
+
while (!predicate()) {
|
|
221
|
+
if (Date.now() - started > timeout) {
|
|
222
|
+
throw new Error('Condition not reached in time')
|
|
223
|
+
}
|
|
224
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The publisher's in-band authentication (`createWsAuthentication` in viable's
|
|
230
|
+
* `sources/publisher/src/helpers/ws-auth.ts`): a valid credential announces the system
|
|
231
|
+
* `authenticated` frame to the connection's own listeners; a refused one closes the socket.
|
|
232
|
+
*/
|
|
233
|
+
export const makeAgentAuthentication = (conn: Connection): AuthenticateMethod => (async (
|
|
234
|
+
stage: AuthenticationStage, payload: { token?: string },
|
|
235
|
+
) => {
|
|
236
|
+
if (stage !== AuthenticationStage.Authenticate || payload?.token !== AGENT_TOKEN) {
|
|
237
|
+
await conn.close()
|
|
238
|
+
|
|
239
|
+
return [AuthenticationStage.Error, null]
|
|
240
|
+
}
|
|
241
|
+
const msg: EventMessage<void> = { type: MessageType.System, event: 'authenticated', payload: undefined }
|
|
242
|
+
conn.getListeners().map(listener => listener(msg))
|
|
243
|
+
|
|
244
|
+
return [AuthenticationStage.Authenticated, true]
|
|
245
|
+
}) as AuthenticateMethod
|
|
246
|
+
|
|
247
|
+
/** A slot's change feed and a pub/sub topic: the in-process sources the real handlers subscribe to. */
|
|
248
|
+
export const makeFeed = (): EventEmitter => new EventEmitter()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AuthenticationStage, AuthorizationError } from '@owlmeans/auth'
|
|
3
|
+
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
4
|
+
import { ResilientError } from '@owlmeans/error'
|
|
5
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
6
|
+
import { MessageType } from '@owlmeans/socket'
|
|
7
|
+
import type { EventMessage } from '@owlmeans/socket'
|
|
8
|
+
import { connection, handleConnection } from '../src/helper.js'
|
|
9
|
+
import {
|
|
10
|
+
AGENT_TOKEN, GUARD_TOKEN, THINKING_EVENT, WATCH_EVENT, connectClient, makeAgentAuthentication, makeFeed,
|
|
11
|
+
protocols, roundTrip, startSocketServer, until,
|
|
12
|
+
} from './context.js'
|
|
13
|
+
import type { SocketServer, WatchChange } from './context.js'
|
|
14
|
+
|
|
15
|
+
const isSystem = (raw: unknown, event: string): boolean => typeof raw === 'object' && raw != null
|
|
16
|
+
&& (raw as EventMessage<void>).type === MessageType.System && (raw as EventMessage<void>).event === event
|
|
17
|
+
|
|
18
|
+
/** The slot's change feed the publisher's file watch subscribes to once the agent authenticated. */
|
|
19
|
+
const slot = makeFeed()
|
|
20
|
+
/** The pub/sub topic the manager-api's thinking relay subscribes to. */
|
|
21
|
+
const topic = makeFeed()
|
|
22
|
+
/** Close codes the thinking relay's server-side close listener observed. */
|
|
23
|
+
const relayClosed: number[] = []
|
|
24
|
+
/** How many times each handler was reached. */
|
|
25
|
+
const reached: Record<string, number> = { guarded: 0, gated: 0 }
|
|
26
|
+
/** Frames the guarded handler observed. */
|
|
27
|
+
const guardedEvents: unknown[] = []
|
|
28
|
+
|
|
29
|
+
const refusal = new AuthorizationError('slot')
|
|
30
|
+
/** A refusal whose marshalled form outgrows a close frame's 123-byte reason. */
|
|
31
|
+
const longRefusal = new AuthorizationError(`slot:${'ü'.repeat(80)}`)
|
|
32
|
+
const bytes = (text: string): number => new TextEncoder().encode(text).length
|
|
33
|
+
|
|
34
|
+
const entrypoints = [
|
|
35
|
+
// Every real Viable socket: a `connection()` handler that wires listeners and resolves nothing.
|
|
36
|
+
bind(protocols.idle, connection(protocols.idle, async conn => {
|
|
37
|
+
conn.listen(async () => undefined)
|
|
38
|
+
})),
|
|
39
|
+
|
|
40
|
+
// The publisher's file watch (viable `sources/publisher/src/app/update/files.ts`).
|
|
41
|
+
bind(protocols.files, connection(protocols.files, async conn => {
|
|
42
|
+
conn.listen(async raw => {
|
|
43
|
+
if (!isSystem(raw, 'authenticated')) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const onChange = (change: WatchChange): void => { void conn.notify(WATCH_EVENT, change) }
|
|
47
|
+
slot.on('change', onChange)
|
|
48
|
+
conn.listen(async msg => {
|
|
49
|
+
if (isSystem(msg, 'close')) {
|
|
50
|
+
slot.off('change', onChange)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
conn.authenticate = makeAgentAuthentication(conn)
|
|
55
|
+
})),
|
|
56
|
+
|
|
57
|
+
// The manager-api's thinking relay (viable `sources/manager-api/src/app/project/thinking.ts`).
|
|
58
|
+
bind(protocols.thinking, handleConnection(async conn => {
|
|
59
|
+
conn.authenticate = makeAgentAuthentication(conn)
|
|
60
|
+
let unsubscribe: (() => void) | undefined
|
|
61
|
+
conn.listen(async raw => {
|
|
62
|
+
if (isSystem(raw, 'authenticated')) {
|
|
63
|
+
const push = (entry: unknown): void => { void conn.notify(THINKING_EVENT, entry) }
|
|
64
|
+
topic.on('thinking', push)
|
|
65
|
+
unsubscribe = () => topic.off('thinking', push)
|
|
66
|
+
} else if (isSystem(raw, 'close')) {
|
|
67
|
+
relayClosed.push((raw as EventMessage<{ code: number }>).payload.code)
|
|
68
|
+
unsubscribe?.()
|
|
69
|
+
unsubscribe = undefined
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})),
|
|
73
|
+
|
|
74
|
+
bind(protocols.failing, connection(protocols.failing, async (_conn, _ctx, request) => {
|
|
75
|
+
throw (request.query as Record<string, string> | undefined)?.long != null ? longRefusal : refusal
|
|
76
|
+
})),
|
|
77
|
+
|
|
78
|
+
bind(protocols.once, handleConnection(async (_conn, _ctx, _req, res) => {
|
|
79
|
+
res.resolve({ ready: true }, EntrypointOutcome.Ok)
|
|
80
|
+
})),
|
|
81
|
+
|
|
82
|
+
bind(protocols.guarded, connection(protocols.guarded, async conn => {
|
|
83
|
+
reached.guarded++
|
|
84
|
+
conn.observe(WATCH_EVENT, async msg => { guardedEvents.push(msg.payload) })
|
|
85
|
+
})),
|
|
86
|
+
|
|
87
|
+
bind(protocols.gated, connection(protocols.gated, async conn => {
|
|
88
|
+
reached.gated++
|
|
89
|
+
conn.listen(async () => undefined)
|
|
90
|
+
})),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
describe('@owlmeans/server-socket — a socket route served end to end', () => {
|
|
94
|
+
let server: SocketServer
|
|
95
|
+
const clients: WebSocket[] = []
|
|
96
|
+
const open = (path: string, query?: Record<string, string>) => {
|
|
97
|
+
const client = connectClient(server.url(path, query))
|
|
98
|
+
clients.push(client.socket)
|
|
99
|
+
return client
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => { server = await startSocketServer(entrypoints) })
|
|
103
|
+
afterAll(async () => {
|
|
104
|
+
clients.forEach(socket => socket.close())
|
|
105
|
+
await server?.close()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a connection handler that only wires listeners keeps the socket open and sends no frame', async () => {
|
|
109
|
+
const client = open('/idle')
|
|
110
|
+
|
|
111
|
+
expect(await client.opened).toBe(true)
|
|
112
|
+
// The ping is answered only by a live server-side socket; a 1011 close would end the race first.
|
|
113
|
+
expect(await roundTrip(client)).toBe(true)
|
|
114
|
+
expect(client.socket.readyState).toBe(WebSocket.OPEN)
|
|
115
|
+
expect(client.frames).toEqual([JSON.stringify({ type: 'pong' })])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('publisher file watch: the agent authenticates, then observes { event, path } changes', async () => {
|
|
119
|
+
const client = open('/update/files')
|
|
120
|
+
const changes: WatchChange[] = []
|
|
121
|
+
client.connection.observe<WatchChange>(WATCH_EVENT, async msg => { changes.push(msg.payload) })
|
|
122
|
+
|
|
123
|
+
expect(await client.opened).toBe(true)
|
|
124
|
+
expect(await client.connection.auth<{ token: string }, boolean>(AuthenticationStage.Authenticate, { token: AGENT_TOKEN }))
|
|
125
|
+
.toBe(true)
|
|
126
|
+
expect(client.connection.stage).toBe(AuthenticationStage.Authenticated)
|
|
127
|
+
await until(() => slot.listenerCount('change') === 1)
|
|
128
|
+
|
|
129
|
+
slot.emit('change', { event: 'change', path: 'src/index.ts' })
|
|
130
|
+
await until(() => changes.length === 1)
|
|
131
|
+
expect(changes).toEqual([{ event: 'change', path: 'src/index.ts' }])
|
|
132
|
+
|
|
133
|
+
client.socket.close()
|
|
134
|
+
await client.closed
|
|
135
|
+
await until(() => slot.listenerCount('change') === 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('publisher file watch: a refused authentication is closed by the server, nothing is watched', async () => {
|
|
139
|
+
const client = open('/update/files')
|
|
140
|
+
|
|
141
|
+
expect(await client.opened).toBe(true)
|
|
142
|
+
void client.connection.auth(AuthenticationStage.Authenticate, { token: 'forged' }).catch(() => undefined)
|
|
143
|
+
|
|
144
|
+
await client.closed
|
|
145
|
+
expect(slot.listenerCount('change')).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('publisher file watch: a frame other than authentication before it closes the socket with 1008', async () => {
|
|
149
|
+
const client = open('/update/files')
|
|
150
|
+
|
|
151
|
+
expect(await client.opened).toBe(true)
|
|
152
|
+
client.socket.send(JSON.stringify({ type: MessageType.Event, event: WATCH_EVENT, payload: {} }))
|
|
153
|
+
|
|
154
|
+
expect((await client.closed).code).toBe(1008)
|
|
155
|
+
expect(slot.listenerCount('change')).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a handler that throws is rejected: closed with 1011 and the marshalled refusal as reason', async () => {
|
|
159
|
+
const client = open('/failing')
|
|
160
|
+
|
|
161
|
+
const closed = await client.closed
|
|
162
|
+
expect(closed.code).toBe(1011)
|
|
163
|
+
// Type and message only: a server stack never reaches the client.
|
|
164
|
+
expect(closed.reason).toBe(ResilientError.ensure(refusal).marshal({ includeStack: false }).message)
|
|
165
|
+
expect(ResilientError.ensure(new Error(closed.reason))).toBeInstanceOf(AuthorizationError)
|
|
166
|
+
expect(client.frames).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('a refusal longer than a close frame allows is clipped to 123 bytes on a character boundary', async () => {
|
|
170
|
+
const client = open('/failing', { long: '1' })
|
|
171
|
+
|
|
172
|
+
const closed = await client.closed
|
|
173
|
+
const full = ResilientError.ensure(longRefusal).marshal({ includeStack: false }).message
|
|
174
|
+
expect(closed.code).toBe(1011)
|
|
175
|
+
expect(bytes(full)).toBeGreaterThan(123)
|
|
176
|
+
expect(bytes(closed.reason)).toBeLessThanOrEqual(123)
|
|
177
|
+
expect(full.startsWith(closed.reason)).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('a handler resolving a value with the Ok outcome sends it once and closes', async () => {
|
|
181
|
+
const client = open('/once')
|
|
182
|
+
|
|
183
|
+
await client.closed
|
|
184
|
+
expect(client.frames).toEqual([JSON.stringify({ ready: true })])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('thinking relay: a client close runs the server-side close listener, which unsubscribes', async () => {
|
|
188
|
+
const client = open('/update/thinking')
|
|
189
|
+
const entries: unknown[] = []
|
|
190
|
+
client.connection.observe(THINKING_EVENT, async msg => { entries.push(msg.payload) })
|
|
191
|
+
|
|
192
|
+
expect(await client.opened).toBe(true)
|
|
193
|
+
await client.connection.auth(AuthenticationStage.Authenticate, { token: AGENT_TOKEN })
|
|
194
|
+
await until(() => topic.listenerCount('thinking') === 1)
|
|
195
|
+
topic.emit('thinking', { step: 'plan' })
|
|
196
|
+
await until(() => entries.length === 1)
|
|
197
|
+
expect(entries).toEqual([{ step: 'plan' }])
|
|
198
|
+
|
|
199
|
+
await client.connection.close()
|
|
200
|
+
await until(() => topic.listenerCount('thinking') === 0)
|
|
201
|
+
expect(relayClosed).toHaveLength(1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('a guard refusing the upgrade never reaches the handler', async () => {
|
|
205
|
+
const client = open('/guarded')
|
|
206
|
+
|
|
207
|
+
expect(await client.opened).toBe(false)
|
|
208
|
+
const forged = open('/guarded', { token: 'forged' })
|
|
209
|
+
expect(await forged.opened).toBe(false)
|
|
210
|
+
expect(reached.guarded).toBe(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a guard admitting the upgrade authenticates the connection: frames flow without in-band auth', async () => {
|
|
214
|
+
const client = open('/guarded', { token: GUARD_TOKEN })
|
|
215
|
+
|
|
216
|
+
expect(await client.opened).toBe(true)
|
|
217
|
+
await until(() => reached.guarded === 1)
|
|
218
|
+
await client.connection.notify(WATCH_EVENT, { event: 'add', path: 'README.md' })
|
|
219
|
+
await until(() => guardedEvents.length === 1)
|
|
220
|
+
expect(guardedEvents).toEqual([{ event: 'add', path: 'README.md' }])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('a closed gate refuses the upgrade before the handler; an open one lets it through', async () => {
|
|
224
|
+
const refused = open('/gated')
|
|
225
|
+
expect(await refused.opened).toBe(false)
|
|
226
|
+
expect(reached.gated).toBe(0)
|
|
227
|
+
|
|
228
|
+
const admitted = open('/gated', { gate: 'open' })
|
|
229
|
+
expect(await admitted.opened).toBe(true)
|
|
230
|
+
await until(() => reached.gated === 1)
|
|
231
|
+
expect(server.gate.asserted).toEqual([['watch'], ['watch']])
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('@owlmeans/server-socket — server shutdown', () => {
|
|
236
|
+
test('closing the server closes every open socket with 1001', async () => {
|
|
237
|
+
const server = await startSocketServer([
|
|
238
|
+
bind(protocols.idle, connection(protocols.idle, async conn => { conn.listen(async () => undefined) })),
|
|
239
|
+
])
|
|
240
|
+
const client = connectClient(server.url('/idle'))
|
|
241
|
+
expect(await client.opened).toBe(true)
|
|
242
|
+
expect(await roundTrip(client)).toBe(true)
|
|
243
|
+
|
|
244
|
+
await server.close()
|
|
245
|
+
expect((await client.closed).code).toBe(1001)
|
|
246
|
+
})
|
|
247
|
+
})
|
package/tsconfig.json
CHANGED