@owlmeans/server-socket 0.1.18-rc.55 → 0.1.18-rc.57

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 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.55
14
+ bun add @owlmeans/server-socket@^0.1.18-rc.57
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.51
213
+ npx @owlmeans/agent-skills@^0.1.18-rc.53
214
214
  ```
215
215
 
216
216
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-socket",
4
- "version": "0.1.18-rc.55",
5
- "generatedAt": "2026-10-06T11:45:57.676Z",
4
+ "version": "0.1.18-rc.57",
5
+ "generatedAt": "2026-10-09T11:30:42.757Z",
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.55`
9
+ **Install:** `bun add @owlmeans/server-socket@^0.1.18-rc.57`
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. Do not expose
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,3 @@
1
+ /** The longest close reason a WebSocket close frame carries, in UTF-8 bytes (RFC 6455). */
2
+ export declare const CLOSE_REASON_LIMIT = 123;
3
+ //# sourceMappingURL=consts.local.d.ts.map
@@ -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,3 @@
1
+ /** The longest close reason a WebSocket close frame carries, in UTF-8 bytes (RFC 6455). */
2
+ export const CLOSE_REASON_LIMIT = 123;
3
+ //# sourceMappingURL=consts.local.js.map
@@ -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"}
@@ -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;AAgBpE,eAAO,MAAM,mBAAmB,WAAW,MAAM,KAAmB,aA+GnE,CAAA;AAED,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OACnE,CAAC,UAAS,MAAM,KACpB,CAOF,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
- conn.send(typeof value === 'string' ? value : JSON.stringify(value));
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);
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
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.55",
3
+ "version": "0.1.18-rc.57",
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.40",
35
- "@owlmeans/basic-envelope": "^0.1.18-rc.47",
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/log": "^0.1.18-rc.2",
39
- "@owlmeans/server-api": "^0.1.18-rc.52",
40
- "@owlmeans/server-auth": "^0.1.18-rc.55",
41
- "@owlmeans/server-context": "^0.1.18-rc.43",
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.57",
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.41"
47
+ "@owlmeans/socket": "^0.1.18-rc.43"
44
48
  },
45
49
  "publishConfig": {
46
50
  "access": "public"
@@ -0,0 +1,3 @@
1
+
2
+ /** The longest close reason a WebSocket close frame carries, in UTF-8 bytes (RFC 6455). */
3
+ export const CLOSE_REASON_LIMIT = 123
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
- conn.send(typeof value === 'string' ? value : JSON.stringify(value))
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
+ })
@@ -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
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
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
  }