@owlmeans/server-socket 0.1.18-rc.27 → 0.1.18-rc.28

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
@@ -1,74 +1,205 @@
1
1
  # @owlmeans/server-socket
2
2
 
3
- Server-side WebSocket connection handler and service for OwlMeans backends.
4
-
5
- ## Overview
6
-
7
- - `connection(protocol, callback)` creates a typed WebSocket binding for one protocol
8
- - `appendSocketService()` + `createSocketMiddleware()` wire the WebSocket service into a server context
9
- - Used in viable for real-time thinking journal updates and file watching
10
- - The `Connection` interface (from `@owlmeans/socket`) is the runtime object passed to your handler
3
+ The server WebSocket carrier for OwlMeans backends: binds socket protocol declarations to connection
4
+ handlers on the Fastify API server. An application uses `connection(protocol, callback)` for push
5
+ and streaming channels — live notifications, file or job progress, RPC over one long-lived
6
+ connection. `@owlmeans/server-app`'s `makeContext` already registers the socket service and its
7
+ middleware. Request/response HTTP routes use `handlers<Context>()` from `@owlmeans/server-api`, the
8
+ browser side is `@owlmeans/client-socket`, and background work that must survive a restart belongs
9
+ in a queue (`@owlmeans/server-job`).
11
10
 
12
11
  ## Installation
13
12
 
14
13
  ```bash
15
- bun add @owlmeans/server-socket@^0.1.18-rc.17
14
+ bun add @owlmeans/server-socket@^0.1.18-rc.27
16
15
  ```
17
16
 
17
+ ## Concepts
18
+
19
+ - **Socket protocol** — a declaration whose route is built with `socket()` from `@owlmeans/route`.
20
+ The HTTP server skips it; the socket service mounts it as a `GET` WebSocket route.
21
+ - **Connection** — the `@owlmeans/socket` `Connection` model. This package assigns its `send`,
22
+ `close`, `authenticate` and `prepare`; the handler uses `notify`, `listen`, `observe`, `perform`,
23
+ `call` and `close`.
24
+ - **Upgrade-time guards** — the protocol's guards and gates run before the upgrade with the same
25
+ pipeline as HTTP, including organization-entity resolution, so a guarded socket arrives with
26
+ `request.auth` and `request.entity` set.
27
+ - **In-band authentication** — a socket that receives its token after the connection is open
28
+ assigns `conn.authenticate`; that code path establishes authentication and must resolve the entity
29
+ itself.
30
+ - **System frames** — on close, listeners receive `{ type: MessageType.System, event: 'close',
31
+ payload: { code } }`. A thrown callback closes the socket with code 1011; server shutdown closes
32
+ open sockets with 1001.
33
+
18
34
  ## Usage
19
35
 
20
- Handle a WebSocket connection on an entrypoint route:
36
+ ### Declare and bind
21
37
 
22
- ```typescript
23
- import { connection } from '@owlmeans/server-socket'
24
- import { bind } from '@owlmeans/server-entrypoint'
25
- import type { Connection } from '@owlmeans/socket'
26
- import { appEntrypoints as protocols } from 'my-app-common'
38
+ ```ts
39
+ // my-app-common
40
+ import { contract, protocol, typed } from '@owlmeans/entrypoint'
41
+ import { route, socket } from '@owlmeans/route'
27
42
 
28
- const watch = connection(protocols.api.fileWatch, async (conn, context, req) => {
29
- const ctx = context as Context
30
- const projectId = req.params.id as string
43
+ export const projectProtocols = {
44
+ // ...base, list, create
45
+ events: protocol(
46
+ route('my-app:api:project:events', '/:id/events', socket({ parent: projectBase })),
47
+ contract.request({ params: typed<ProjectParams>(ProjectParamsSchema) }, typed<void>()),
48
+ ),
49
+ }
50
+ ```
31
51
 
32
- // Observe events sent by the client
33
- const unsubscribe = conn.observe<FileEvent>('file-change', async (event) => {
34
- await ctx.fileStore().save({ projectId, ...event.payload })
35
- })
52
+ ```ts
53
+ // my-app-api
54
+ import { bind } from '@owlmeans/server-app'
55
+ import { connection } from '@owlmeans/server-socket'
56
+ import { projectProtocols } from 'my-app-common'
57
+ import type { Context } from './context.js'
36
58
 
37
- // Push updates to the client
38
- const unsubscribeStore = await ctx.fileStore().subscribe(record => {
39
- conn.notify('file-update', record)
59
+ const events = connection<typeof projectProtocols.events, Context>(projectProtocols.events,
60
+ async (conn, context, request) => {
61
+ await conn.notify('project:ready', { id: request.params.id })
40
62
  })
41
- })
42
63
 
43
- export const entrypoints = [bind(protocols.api.fileWatch, watch)]
64
+ export const appEntrypoints = [
65
+ bind(projectProtocols.events, events),
66
+ ]
44
67
  ```
45
68
 
46
- ## API
69
+ ### Push organization-scoped events and clean up
70
+
71
+ The parent declares `guards`, so authentication and the entity are resolved before the upgrade.
72
+
73
+ ```ts
74
+ import { requireEntityKey } from '@owlmeans/auth-common'
75
+ import { MessageType } from '@owlmeans/socket'
76
+ import type { EventMessage } from '@owlmeans/socket'
77
+
78
+ export const events = connection<typeof projectProtocols.events, Context>(projectProtocols.events,
79
+ async (conn, context, request) => {
80
+ const entityId = requireEntityKey(request)
81
+
82
+ // app-local pub/sub resource, one channel per organization
83
+ const unsubscribe = await context.projectEvents().subscribe(async event => {
84
+ if (event.projectId === request.params.id) {
85
+ await conn.notify('project:event', event)
86
+ }
87
+ }, { channel: entityId })
88
+
89
+ conn.listen(async raw => {
90
+ if (typeof raw !== 'object') return
91
+ const message = raw as EventMessage<void>
92
+ if (message.type === MessageType.System && message.event === 'close') {
93
+ await unsubscribe()
94
+ }
95
+ })
96
+ })
97
+ ```
98
+
99
+ ### Authenticate in-band
100
+
101
+ When the client cannot send a bearer on the upgrade request, it sends the token as the first auth
102
+ frame. The handler runs the guard itself and must call `attachEntity`.
47
103
 
48
- ### `connection(protocol, handler): BoundEntrypointHandler`
104
+ ```ts
105
+ import { AUTH_HEADER, AuthenFailed, AuthenticationStage, AuthUnknown } from '@owlmeans/auth'
106
+ import type { Auth, AuthToken } from '@owlmeans/auth'
107
+ import { attachEntity, DEFAULT_GUARD, requireEntityKey } from '@owlmeans/auth-common'
108
+ import { provideResponse } from '@owlmeans/entrypoint'
109
+ import type { AbstractResponse, GuardService } from '@owlmeans/entrypoint'
110
+ import type { AuthenticateMethod } from '@owlmeans/socket'
49
111
 
50
- Creates a protocol-bound WebSocket handler. The callback receives `(conn, ctx, req, res)` with
51
- request and response types inferred from the protocol.
112
+ export const watch = connection<typeof projectProtocols.watch, Context>(projectProtocols.watch,
113
+ async (conn, context, request) => {
114
+ const timeout = setTimeout(() => void conn.close(), 5_000)
52
115
 
53
- ```typescript
54
- handler: (conn: T, ctx: Context, req: AbstractRequest, res: AbstractResponse) => Promise<void>
116
+ conn.authenticate = (async (stage: AuthenticationStage, token: AuthToken) => {
117
+ if (stage !== AuthenticationStage.Authenticate) {
118
+ throw new AuthUnknown('stage')
119
+ }
120
+
121
+ request.headers[AUTH_HEADER] ??= token.token
122
+ const response: AbstractResponse<Auth> = provideResponse({ header: () => void 0 })
123
+ const guard = context.service<GuardService>(DEFAULT_GUARD)
124
+ if (!await guard.handle(request, response) || response.value == null) {
125
+ throw new AuthenFailed('guard')
126
+ }
127
+
128
+ request.auth = response.value
129
+ // This path establishes authentication, so it resolves the organization entity itself.
130
+ await attachEntity(context, request)
131
+ clearTimeout(timeout)
132
+
133
+ await startWatching(conn, context, requireEntityKey(request))
134
+
135
+ return [AuthenticationStage.Authenticated, true]
136
+ }) as AuthenticateMethod
137
+ })
55
138
  ```
56
139
 
57
- The `conn` object is a server-side `Connection` (from `@owlmeans/socket`).
140
+ ### RPC over a connection
58
141
 
59
- ### `appendSocketService<C, T>(context): void`
142
+ ```ts
143
+ export const terminal = connection<typeof projectProtocols.terminal, Context>(projectProtocols.terminal,
144
+ async (conn, context, request) => {
145
+ const entityId = requireEntityKey(request)
60
146
 
61
- Registers the WebSocket service into the context. Called automatically by `makeContext()`.
147
+ conn.perform('project:rename', async (id: string, name: string) => {
148
+ const project = await context.project().load({ id, entityId })
149
+ return project == null ? null : context.project().update({ ...project, name })
150
+ })
151
+ })
152
+ ```
153
+
154
+ The browser side calls it with `conn.call('project:rename', id, name)` on a
155
+ `@owlmeans/client-socket` connection.
62
156
 
63
- ### `createSocketMiddleware(): Middleware`
157
+ ### A context without `@owlmeans/server-app`
64
158
 
65
- Creates the middleware that initializes the socket service during context init.
159
+ ```ts
160
+ import { appendApiServer } from '@owlmeans/server-api'
161
+ import { appendSocketService, createSocketMiddleware } from '@owlmeans/server-socket'
66
162
 
67
- ## Related Packages
163
+ appendApiServer(context)
164
+ appendSocketService(context)
165
+ context.registerMiddleware(createSocketMiddleware())
166
+ ```
167
+
168
+ ## API
68
169
 
69
- - [`@owlmeans/socket`](../socket) — `Connection`, `MessageType`, `EventMessage` types
170
+ | Symbol | Kind | Purpose |
171
+ |---|---|---|
172
+ | `connection<Protocol, SocketContext, SocketConnection>(protocol, handler)` | function | Bind a socket protocol to `(connection, context, request, response) => Promise<void>`; returns `BoundEntrypointHandler<Protocol>` |
173
+ | `handleConnection(handler)` | function | Unbound compatibility wrapper returning a `RefedEntrypointHandler` |
174
+ | `appendSocketService(ctx, alias = DEFAULT_ALIAS)` | function | Register the socket service on a context that already has the API server |
175
+ | `createSocketService(alias?)` | function | Create the socket service; `update(apiServer)` registers `@fastify/websocket` and the socket routes |
176
+ | `createSocketMiddleware(web = 'api-server', socket = DEFAULT_ALIAS)` | function | Loading-stage context middleware that attaches the socket service to the API server once |
177
+ | `DEFAULT_ALIAS` | const | `'socket-server'` |
178
+ | `SocketService` | type | `InitializedService` with `update(api: ApiServer)` |
179
+ | `Config`, `Context` | type | `ServerConfig` and `ServerContext & ApiServerAppend` |
180
+
181
+ ## Common pitfalls
182
+
183
+ - Declare the route with `socket()`; without it the declaration is mounted as a plain HTTP `GET`.
184
+ - Bind with the same protocol object on both sides: `bind(p, connection(p, callback))`.
185
+ - Guards and gates are inherited from the protocol tree and enforced before the handler runs; do not
186
+ re-implement them inside the callback.
187
+ - A socket that authenticates on its own must call `attachEntity(context, request)`, or
188
+ `request.entity` stays empty and entity-keyed lookups silently find nothing.
189
+ - The token query parameter is used only to stamp `sender` / `recipient` on frames. It is not
190
+ verified — never treat it as authentication.
191
+ - Release subscriptions and watchers on the System `close` frame. Listeners also receive heartbeat
192
+ frames, so check `type` and `event` before acting.
193
+ - Do not expose Fastify request or socket objects through a shared contract.
194
+
195
+ ## Related packages
196
+
197
+ - [`@owlmeans/socket`](../socket) — `Connection`, `MessageType`, `EventMessage`
198
+ - [`@owlmeans/client-socket`](../client-socket) — the browser and native carrier
199
+ - [`@owlmeans/server-api`](../server-api) — the API server this service attaches to
200
+ - [`@owlmeans/server-entrypoint`](../server-entrypoint) — `bind()`
70
201
  - [`@owlmeans/server-app`](../server-app) — calls `appendSocketService` in `makeContext`
71
- - [`@owlmeans/client-socket`](../client-socket) — client-side counterpart
202
+ - [`@owlmeans/auth-common`](../auth-common) — `attachEntity`, `requireEntityKey`, `DEFAULT_GUARD`
72
203
 
73
204
  <!-- owlmeans:agent-guidance:start -->
74
205
  ## Agent guidance
@@ -78,7 +209,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
78
209
  your project's skill store (`.agents/skills/`):
79
210
 
80
211
  ```sh
81
- npx @owlmeans/agent-skills@^0.1.18-rc.20
212
+ npx @owlmeans/agent-skills@^0.1.18-rc.21
82
213
  ```
83
214
 
84
215
  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.27",
5
- "generatedAt": "2026-09-12T14:18:54.794Z",
4
+ "version": "0.1.18-rc.28",
5
+ "generatedAt": "2026-09-15T12:21:31.933Z",
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.25`
9
+ **Install:** `bun add @owlmeans/server-socket@^0.1.18-rc.27`
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()`.
@@ -15,8 +15,8 @@ Declare a socket route and its contract in the shared protocol package. Bind it
15
15
  import { bind } from '@owlmeans/server-entrypoint'
16
16
  import { connection } from '@owlmeans/server-socket'
17
17
 
18
- export const appEntrypoints = [
19
- bind(streamEntrypoints.watch, connection(streamEntrypoints.watch,
18
+ export const serverBindings = [
19
+ bind(streamProtocols.watch, connection(streamProtocols.watch,
20
20
  async (connection, context, request) => {
21
21
  await connection.send({ type: 'ready' })
22
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-socket",
3
- "version": "0.1.18-rc.27",
3
+ "version": "0.1.18-rc.28",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -31,15 +31,15 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@fastify/websocket": "^11.0.1",
34
- "@owlmeans/auth": "^0.1.18-rc.17",
35
- "@owlmeans/basic-envelope": "^0.1.18-rc.20",
36
- "@owlmeans/context": "^0.1.18-rc.16",
37
- "@owlmeans/entrypoint": "^0.1.18-rc.19",
38
- "@owlmeans/server-api": "^0.1.18-rc.25",
39
- "@owlmeans/server-auth": "^0.1.18-rc.27",
40
- "@owlmeans/server-context": "^0.1.18-rc.20",
41
- "@owlmeans/server-entrypoint": "^0.1.18-rc.19",
42
- "@owlmeans/socket": "^0.1.18-rc.17"
34
+ "@owlmeans/auth": "^0.1.18-rc.18",
35
+ "@owlmeans/basic-envelope": "^0.1.18-rc.21",
36
+ "@owlmeans/context": "^0.1.18-rc.17",
37
+ "@owlmeans/entrypoint": "^0.1.18-rc.20",
38
+ "@owlmeans/server-api": "^0.1.18-rc.26",
39
+ "@owlmeans/server-auth": "^0.1.18-rc.28",
40
+ "@owlmeans/server-context": "^0.1.18-rc.21",
41
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.20",
42
+ "@owlmeans/socket": "^0.1.18-rc.18"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"