@afora/ws 0.0.0-stage → 0.0.8
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 +168 -2
- package/dist/exceptions.d.ts +10 -0
- package/dist/hub.d.ts +24 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +361 -0
- package/dist/protocol.d.ts +50 -0
- package/dist/sink.d.ts +100 -0
- package/package.json +55 -5
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @afora/ws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A WebSocket sink for [`@afora/core`](../../README.md) outputs. It serves the operations of an output over JSON messages, with the same validation, masked responses and RFC 9457 problems as the HTTP sink, and lets a connection subscribe to topics that [`publishEvents`](../../docs/commands.md#event-policies) fills with domain events. It depends on no runtime: the sink builds an endpoint, and a few lines of Bun, Hono or Workers feed it the messages of a socket.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun add @afora/core @afora/ws typebox
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`@afora/core` and TypeBox (`^1`) are peer dependencies. The package uses only web APIs, so it runs on Bun, Node >= 22 and Workers.
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// realtime.ts
|
|
15
|
+
import { wsHub } from "@afora/ws";
|
|
16
|
+
export const hub = wsHub();
|
|
17
|
+
|
|
18
|
+
// app.ts
|
|
19
|
+
import { applicationOf, commandOf, outputOf, publishEvents } from "@afora/core";
|
|
20
|
+
import { websocket } from "@afora/ws";
|
|
21
|
+
import { hub } from "./realtime";
|
|
22
|
+
|
|
23
|
+
const Posts = commandOf({
|
|
24
|
+
name: "Posts",
|
|
25
|
+
useCases: { create: CreatePost, list: ListPosts },
|
|
26
|
+
events: publishEvents({ publisher: "realtime", topic: "posts" }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const Live = outputOf({
|
|
30
|
+
name: "Live",
|
|
31
|
+
commands: [Posts],
|
|
32
|
+
pipe: [
|
|
33
|
+
websocket({
|
|
34
|
+
context: { userId: "x-user" },
|
|
35
|
+
subscriptions: { topics: ["posts", "posts/*"] },
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const App = applicationOf({
|
|
41
|
+
name: "Blog",
|
|
42
|
+
commands: { Posts },
|
|
43
|
+
outputs: { Live },
|
|
44
|
+
publishers: { realtime: hub },
|
|
45
|
+
// ...entities, repositories, use cases
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
→ { "id": 1, "subscribe": "posts" }
|
|
51
|
+
← { "id": 1, "subscribed": "posts" }
|
|
52
|
+
|
|
53
|
+
→ { "id": 2, "op": "Posts.create", "input": { "title": "Hello" } }
|
|
54
|
+
← { "id": 2, "value": { "post": { "id": "...", "title": "Hello", ... } } }
|
|
55
|
+
← { "topic": "posts", "event": { "name": "postCreated", "command": "Posts", "operation": "create", "entity": { ... } } }
|
|
56
|
+
|
|
57
|
+
→ { "id": 3, "op": "Posts.create", "input": {} }
|
|
58
|
+
← { "id": 3, "problem": { "type": "urn:afora:exception:InvalidUseCaseInput", "status": 400, "issues": [...], ... } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The push goes to every socket subscribed to the topic, including the one that made the call, after the call succeeded and committed.
|
|
62
|
+
|
|
63
|
+
## Adapters
|
|
64
|
+
|
|
65
|
+
Build the endpoint once (`App.outputs.Live.build()`), then give each socket a session. The session reads each message and sends the reply through `socket.send`; `close()` leaves its topics.
|
|
66
|
+
|
|
67
|
+
**Bun**
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const endpoint = App.outputs.Live.build();
|
|
71
|
+
|
|
72
|
+
Bun.serve<{ values: Record<string, string | undefined>; session?: WsSession }>({
|
|
73
|
+
fetch(request, server) {
|
|
74
|
+
const values = { "x-user": request.headers.get("x-user") ?? undefined };
|
|
75
|
+
if (server.upgrade(request, { data: { values } })) return undefined;
|
|
76
|
+
return new Response("Upgrade required", { status: 426 });
|
|
77
|
+
},
|
|
78
|
+
websocket: {
|
|
79
|
+
open(ws) {
|
|
80
|
+
ws.data.session = endpoint.session(ws, { hub, values: ws.data.values });
|
|
81
|
+
},
|
|
82
|
+
message(ws, message) {
|
|
83
|
+
void ws.data.session?.message(message);
|
|
84
|
+
},
|
|
85
|
+
close(ws) {
|
|
86
|
+
ws.data.session?.close();
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Hono** (`upgradeWebSocket` from `hono/bun` or `hono/cloudflare-workers`; on Node, `@hono/node-ws`)
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
app.get(
|
|
96
|
+
"/ws",
|
|
97
|
+
upgradeWebSocket((c) => {
|
|
98
|
+
let session: WsSession;
|
|
99
|
+
return {
|
|
100
|
+
onOpen: (_event, ws) => {
|
|
101
|
+
session = endpoint.session(ws, {
|
|
102
|
+
hub,
|
|
103
|
+
values: { "x-user": c.req.header("x-user") },
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
onMessage: (event) => session.message(event.data as string),
|
|
107
|
+
onClose: () => session.close(),
|
|
108
|
+
};
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Workers**
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
117
|
+
server.accept();
|
|
118
|
+
const session = endpoint.session(server, { hub, values });
|
|
119
|
+
server.addEventListener("message", (event) => session.message(event.data));
|
|
120
|
+
server.addEventListener("close", () => session.close());
|
|
121
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`wsHub()` keeps its subscribers in memory, so it reaches the sockets of one process or isolate. To reach every instance, implement `WsHub` (`subscribe`, `unsubscribe`, `size`, `publish`) over a broker: Bun's `ws.subscribe` and `server.publish`, Redis, or a Durable Object that holds the sockets.
|
|
125
|
+
|
|
126
|
+
## Options
|
|
127
|
+
|
|
128
|
+
All options are JSON, so the stage can live in an `afora.json` (`{ "sink": "websocket", "options": { ... } }`).
|
|
129
|
+
|
|
130
|
+
| Option | Meaning |
|
|
131
|
+
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| `include` | Operation ids to serve (`"Posts.create"`); every operation when omitted. An id that is not an operation throws `InvalidOutputStage` on build. |
|
|
133
|
+
| `context` | `{ [contextKey]: valueName }`: the context of an operation from the `values` of the session (headers of the upgrade, a token). |
|
|
134
|
+
| `subscriptions` | `{ topics: ["posts", "posts/*"] }`: the topics a session may subscribe to; `*` is one segment between `/`. Without it, every subscription is refused. |
|
|
135
|
+
| `maxMessageBytes` | The largest message read, in bytes. 1 MiB by default. |
|
|
136
|
+
| `debug` | `true` adds the `trace` to problems and the real `detail` to 5xx ones. Off by default. |
|
|
137
|
+
|
|
138
|
+
Invalid options throw `InvalidOutputStage` when the stage is made.
|
|
139
|
+
|
|
140
|
+
## Protocol
|
|
141
|
+
|
|
142
|
+
| Client sends | Server answers |
|
|
143
|
+
| ------------------------------- | --------------------------------------------------------------------------- |
|
|
144
|
+
| `{ id?, op, input? }` | `{ id, value }` or `{ id, problem }` |
|
|
145
|
+
| `{ id?, subscribe: topic }` | `{ id, subscribed: topic }` or `{ id, problem }` |
|
|
146
|
+
| `{ id?, unsubscribe: topic }` | `{ id, unsubscribed: topic }` |
|
|
147
|
+
| (an event published to a topic) | `{ topic, event: { name, command, operation, entity } }` to its subscribers |
|
|
148
|
+
|
|
149
|
+
- **Calls.** `op` is an operation id. The input is validated by the operation (a 400 problem with `issues`), the response goes through `outputValueOf` (sensitive values masked), and an error is `problemOf` with the status of `statusOf` and the output `statuses`. An unknown `op` is a 404 `NotFound` problem.
|
|
150
|
+
- **`id`** is a non-empty string or a number, echoed in the reply; `null` when the request had none.
|
|
151
|
+
- **Invalid messages.** Text that is not UTF-8 or JSON, a frame that is not an object, an unknown key, not exactly one of `op`, `subscribe` and `unsubscribe`, `input` without `op`, or a message over `maxMessageBytes`: a 400 problem `InvalidWsMessage`, whose `detail` says why.
|
|
152
|
+
- **Subscriptions.** A topic must match `subscriptions.topics`, then `authorize(topic, values)` of the session, if given (ownership, roles): otherwise a 403 `WsTopicForbidden`. A session without a hub, or `handle`, answers a 501 `WsSubscriptionsUnavailable`. The output `statuses` can change these three statuses.
|
|
153
|
+
- **Never throws.** `session.message` catches everything and answers a problem; a socket whose `send` throws is dropped by the hub.
|
|
154
|
+
|
|
155
|
+
`endpoint.handle(message, { values })` answers one message with no connection (calls only), for tests or a transport that is not a socket. `endpoint.operations` lists the operations served with their input JSON Schema.
|
|
156
|
+
|
|
157
|
+
## Security
|
|
158
|
+
|
|
159
|
+
- Authenticate on the upgrade, then pass what you trust as `values`; the client never chooses its context.
|
|
160
|
+
- Topics are private by default. Published entities are masked by `toJSON`, but the other fields reach every subscriber of a topic: scope topics (`posts/{id}`, `users/{userId}`) and check them in `authorize`.
|
|
161
|
+
|
|
162
|
+
## Catalog
|
|
163
|
+
|
|
164
|
+
`WsStages` is `stageCatalogOf(websocket)`, and `stages` are the entries codegen reads to resolve `{ "sink": "websocket" }` in an `afora.json` (`--stages @afora/ws`):
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { stages } from "@afora/ws";
|
|
168
|
+
// [{ name: "websocket", module: "@afora/ws", export: "websocket", kind: "sink" }]
|
|
169
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A message that is not a frame of the protocol (400); `details.reason` says why. */
|
|
2
|
+
export declare const InvalidWsMessage: import("@afora/core").DeclaredExceptionClass<{
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
}>;
|
|
5
|
+
/** A subscription the topics of the options or `authorize` refuse (403). */
|
|
6
|
+
export declare const WsTopicForbidden: import("@afora/core").DeclaredExceptionClass<{
|
|
7
|
+
readonly topic: string;
|
|
8
|
+
}>;
|
|
9
|
+
/** A subscription sent where there is no hub: `handle`, or a session without one (501). */
|
|
10
|
+
export declare const WsSubscriptionsUnavailable: import("@afora/core").DeclaredExceptionClass<undefined>;
|
package/dist/hub.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { IEventPublisher } from "@afora/core";
|
|
2
|
+
/** What the sink sends messages through: a WebSocket of any runtime has `send`. */
|
|
3
|
+
export interface WsSocket {
|
|
4
|
+
send(text: string): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Who is subscribed to which topic, and an `IEventPublisher`: declared under
|
|
8
|
+
* a key in `applicationOf({ publishers })`, the events `publishEvents` gives
|
|
9
|
+
* it reach the sockets subscribed to their topic.
|
|
10
|
+
*/
|
|
11
|
+
export interface WsHub extends IEventPublisher {
|
|
12
|
+
subscribe(topic: string, socket: WsSocket): void;
|
|
13
|
+
unsubscribe(topic: string, socket: WsSocket): void;
|
|
14
|
+
/** How many sockets are subscribed to `topic`. */
|
|
15
|
+
size(topic: string): number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A hub in memory: it reaches the sockets of this process (or Workers
|
|
19
|
+
* isolate) only. `publish` sends `{ topic, event }` as JSON to each socket
|
|
20
|
+
* subscribed to the topic, and drops a socket whose `send` throws; it never
|
|
21
|
+
* throws itself. To reach the sockets of other instances, implement `WsHub`
|
|
22
|
+
* over a broker (Redis, a Durable Object, Bun's `server.publish`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function wsHub(): WsHub;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { InvalidWsMessage, WsSubscriptionsUnavailable, WsTopicForbidden, } from "./exceptions.js";
|
|
2
|
+
export { type WsHub, type WsSocket, wsHub } from "./hub.js";
|
|
3
|
+
export type { WsPush, WsReply, WsRequest, WsRequestId, } from "./protocol.js";
|
|
4
|
+
export { stages, type WebSocketEndpoint, type WebSocketOptions, type WsOperation, type WsSession, type WsSessionOptions, WsStages, type WsValues, websocket, } from "./sink.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// src/exceptions.ts
|
|
2
|
+
import { ExceptionLayers, exceptionOf } from "@afora/core";
|
|
3
|
+
var InvalidWsMessage = exceptionOf({
|
|
4
|
+
layer: ExceptionLayers.Output,
|
|
5
|
+
name: "InvalidWsMessage",
|
|
6
|
+
message: ({ reason }) => reason
|
|
7
|
+
});
|
|
8
|
+
var WsTopicForbidden = exceptionOf({
|
|
9
|
+
layer: ExceptionLayers.Output,
|
|
10
|
+
name: "WsTopicForbidden",
|
|
11
|
+
message: ({ topic }) => `Subscribing to "${topic}" is not allowed.`
|
|
12
|
+
});
|
|
13
|
+
var WsSubscriptionsUnavailable = exceptionOf({
|
|
14
|
+
layer: ExceptionLayers.Output,
|
|
15
|
+
name: "WsSubscriptionsUnavailable",
|
|
16
|
+
message: "Subscriptions need a session with a hub."
|
|
17
|
+
});
|
|
18
|
+
// src/hub.ts
|
|
19
|
+
function wsHub() {
|
|
20
|
+
const topics = new Map;
|
|
21
|
+
const drop = (socket) => {
|
|
22
|
+
for (const [topic, sockets] of topics) {
|
|
23
|
+
sockets.delete(socket);
|
|
24
|
+
if (sockets.size === 0)
|
|
25
|
+
topics.delete(topic);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
subscribe(topic, socket) {
|
|
30
|
+
let sockets = topics.get(topic);
|
|
31
|
+
if (!sockets) {
|
|
32
|
+
sockets = new Set;
|
|
33
|
+
topics.set(topic, sockets);
|
|
34
|
+
}
|
|
35
|
+
sockets.add(socket);
|
|
36
|
+
},
|
|
37
|
+
unsubscribe(topic, socket) {
|
|
38
|
+
const sockets = topics.get(topic);
|
|
39
|
+
if (!sockets)
|
|
40
|
+
return;
|
|
41
|
+
sockets.delete(socket);
|
|
42
|
+
if (sockets.size === 0)
|
|
43
|
+
topics.delete(topic);
|
|
44
|
+
},
|
|
45
|
+
size(topic) {
|
|
46
|
+
return topics.get(topic)?.size ?? 0;
|
|
47
|
+
},
|
|
48
|
+
publish(topic, event) {
|
|
49
|
+
const sockets = topics.get(topic);
|
|
50
|
+
if (!sockets)
|
|
51
|
+
return;
|
|
52
|
+
const text = JSON.stringify({ topic, event });
|
|
53
|
+
for (const socket of [...sockets]) {
|
|
54
|
+
try {
|
|
55
|
+
socket.send(text);
|
|
56
|
+
} catch {
|
|
57
|
+
drop(socket);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// src/sink.ts
|
|
64
|
+
import {
|
|
65
|
+
coerceTextValues,
|
|
66
|
+
InvalidOutputStage,
|
|
67
|
+
outputValueOf,
|
|
68
|
+
problemOf,
|
|
69
|
+
sinkOf,
|
|
70
|
+
stageCatalogOf,
|
|
71
|
+
statusOf,
|
|
72
|
+
toOpenAPI
|
|
73
|
+
} from "@afora/core";
|
|
74
|
+
|
|
75
|
+
// src/protocol.ts
|
|
76
|
+
var KEYS = ["id", "op", "input", "subscribe", "unsubscribe"];
|
|
77
|
+
var VERBS = ["op", "subscribe", "unsubscribe"];
|
|
78
|
+
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
79
|
+
var encoder = new TextEncoder;
|
|
80
|
+
function isId(value) {
|
|
81
|
+
return typeof value === "string" && value !== "" || typeof value === "number" && Number.isFinite(value);
|
|
82
|
+
}
|
|
83
|
+
function parseWsMessage(data, maxBytes) {
|
|
84
|
+
const fail = (reason, id = null) => ({
|
|
85
|
+
failure: { reason, id }
|
|
86
|
+
});
|
|
87
|
+
let frame = data;
|
|
88
|
+
if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
|
89
|
+
const size = typeof data === "string" ? encoder.encode(data).byteLength : data.byteLength;
|
|
90
|
+
if (size > maxBytes) {
|
|
91
|
+
return fail(`The message is larger than ${maxBytes} bytes.`);
|
|
92
|
+
}
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = typeof data === "string" ? data : decoder.decode(data);
|
|
96
|
+
} catch {
|
|
97
|
+
return fail("The message is not valid UTF-8.");
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
frame = JSON.parse(text);
|
|
101
|
+
} catch {
|
|
102
|
+
return fail("The message is not valid JSON.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
|
|
106
|
+
return fail("The message must be a JSON object.");
|
|
107
|
+
}
|
|
108
|
+
const given = frame;
|
|
109
|
+
const id = isId(given.id) ? given.id : null;
|
|
110
|
+
if (given.id !== undefined && id === null) {
|
|
111
|
+
return fail('"id" must be a non-empty string or a number.');
|
|
112
|
+
}
|
|
113
|
+
const unknown = Object.keys(given).find((key) => !KEYS.includes(key));
|
|
114
|
+
if (unknown !== undefined) {
|
|
115
|
+
return fail(`The message has no key "${unknown}".`, id);
|
|
116
|
+
}
|
|
117
|
+
const verbs = VERBS.filter((verb) => Object.hasOwn(given, verb));
|
|
118
|
+
if (verbs.length !== 1) {
|
|
119
|
+
return fail('The message must have exactly one of "op", "subscribe" and "unsubscribe".', id);
|
|
120
|
+
}
|
|
121
|
+
const [verb] = verbs;
|
|
122
|
+
if (typeof given[verb] !== "string" || given[verb] === "") {
|
|
123
|
+
return fail(`"${verb}" must be a non-empty string.`, id);
|
|
124
|
+
}
|
|
125
|
+
if (verb !== "op" && given.input !== undefined) {
|
|
126
|
+
return fail(`"input" only goes with "op".`, id);
|
|
127
|
+
}
|
|
128
|
+
return { request: given };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/sink.ts
|
|
132
|
+
function isObject(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
function validate(options) {
|
|
136
|
+
const given = options;
|
|
137
|
+
for (const key of Object.keys(given)) {
|
|
138
|
+
if (![
|
|
139
|
+
"include",
|
|
140
|
+
"context",
|
|
141
|
+
"subscriptions",
|
|
142
|
+
"maxMessageBytes",
|
|
143
|
+
"debug"
|
|
144
|
+
].includes(key)) {
|
|
145
|
+
return `websocket has no option "${key}".`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const { include, context, subscriptions, maxMessageBytes, debug } = given;
|
|
149
|
+
if (include !== undefined && (!Array.isArray(include) || include.some((id) => typeof id !== "string" || id === ""))) {
|
|
150
|
+
return "Expected websocket.include to be a list of operation ids.";
|
|
151
|
+
}
|
|
152
|
+
if (context !== undefined) {
|
|
153
|
+
if (!isObject(context))
|
|
154
|
+
return "Expected websocket.context to be an object.";
|
|
155
|
+
for (const [key, name] of Object.entries(context)) {
|
|
156
|
+
if (typeof name !== "string" || name === "") {
|
|
157
|
+
return `Expected websocket.context["${key}"] to be a value name.`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (subscriptions !== undefined) {
|
|
162
|
+
if (!isObject(subscriptions) || Object.keys(subscriptions).some((key) => key !== "topics") || !Array.isArray(subscriptions.topics) || subscriptions.topics.some((topic) => typeof topic !== "string" || topic === "" || topic.split("/").some((segment) => segment === ""))) {
|
|
163
|
+
return 'Expected websocket.subscriptions to be { "topics": ["posts/*", ...] }, with no empty segment.';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (maxMessageBytes !== undefined && (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1)) {
|
|
167
|
+
return "Expected websocket.maxMessageBytes to be a whole number of bytes, 1 or more.";
|
|
168
|
+
}
|
|
169
|
+
if (debug !== undefined && typeof debug !== "boolean") {
|
|
170
|
+
return "Expected websocket.debug to be a boolean.";
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
function matches(pattern, topic) {
|
|
175
|
+
const wanted = pattern.split("/");
|
|
176
|
+
const given = topic.split("/");
|
|
177
|
+
return wanted.length === given.length && wanted.every((segment, index) => segment === given[index] || segment === "*" && given[index] !== "");
|
|
178
|
+
}
|
|
179
|
+
var websocket = sinkOf({
|
|
180
|
+
name: "websocket",
|
|
181
|
+
validate,
|
|
182
|
+
build: (operations, options, { output }) => {
|
|
183
|
+
const mapping = options.context ?? {};
|
|
184
|
+
const include = options.include;
|
|
185
|
+
const patterns = options.subscriptions?.topics ?? [];
|
|
186
|
+
const maxBytes = options.maxMessageBytes ?? 1048576;
|
|
187
|
+
const debug = options.debug === true;
|
|
188
|
+
if (include) {
|
|
189
|
+
const known = new Set(operations.map((operation) => operation.id));
|
|
190
|
+
const unknown = include.find((id) => !known.has(id));
|
|
191
|
+
if (unknown !== undefined) {
|
|
192
|
+
throw new InvalidOutputStage({
|
|
193
|
+
source: "websocket",
|
|
194
|
+
details: {
|
|
195
|
+
reason: `websocket.include names "${unknown}", which is not an operation.`
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const served = new Map;
|
|
201
|
+
for (const operation of operations) {
|
|
202
|
+
if (!include || include.includes(operation.id)) {
|
|
203
|
+
served.set(operation.id, operation);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const problem = (error, fallback) => {
|
|
207
|
+
const status = Object.hasOwn(output.statuses, error.name) ? output.statuses[error.name] : fallback;
|
|
208
|
+
return problemOf(error, { status, debug });
|
|
209
|
+
};
|
|
210
|
+
const call = async (request, id, values) => {
|
|
211
|
+
const operation = served.get(request.op);
|
|
212
|
+
if (!operation) {
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
problem: {
|
|
216
|
+
type: "urn:afora:exception:NotFound",
|
|
217
|
+
title: "NotFound",
|
|
218
|
+
status: 404,
|
|
219
|
+
detail: `Unknown operation "${request.op}".`
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const options_ = {};
|
|
224
|
+
if (operation.context) {
|
|
225
|
+
const raw = {};
|
|
226
|
+
for (const key of Object.keys(operation.context.properties)) {
|
|
227
|
+
const source = mapping[key];
|
|
228
|
+
raw[key] = source === undefined ? undefined : values[source];
|
|
229
|
+
}
|
|
230
|
+
options_.context = coerceTextValues(operation.context, raw);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const response = await operation.invoke(request.input ?? {}, options_);
|
|
234
|
+
return { id, value: outputValueOf(response) ?? null };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const status = statusOf(error, operation, output.statuses);
|
|
237
|
+
return { id, problem: problemOf(error, { status, debug }) };
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const read = (data) => {
|
|
241
|
+
const parsed = parseWsMessage(data, maxBytes);
|
|
242
|
+
if ("failure" in parsed) {
|
|
243
|
+
const { reason, id } = parsed.failure;
|
|
244
|
+
return {
|
|
245
|
+
reply: {
|
|
246
|
+
id,
|
|
247
|
+
problem: problem(new InvalidWsMessage({
|
|
248
|
+
source: "websocket",
|
|
249
|
+
details: { reason }
|
|
250
|
+
}), 400)
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { request: parsed.request, id: parsed.request.id ?? null };
|
|
255
|
+
};
|
|
256
|
+
const unavailable = (id) => ({
|
|
257
|
+
id,
|
|
258
|
+
problem: problem(new WsSubscriptionsUnavailable({ source: "websocket" }), 501)
|
|
259
|
+
});
|
|
260
|
+
const handle = async (message, extra) => {
|
|
261
|
+
try {
|
|
262
|
+
const read_ = read(message);
|
|
263
|
+
if ("reply" in read_)
|
|
264
|
+
return read_.reply;
|
|
265
|
+
const { request, id } = read_;
|
|
266
|
+
if ("op" in request)
|
|
267
|
+
return await call(request, id, extra?.values ?? {});
|
|
268
|
+
return unavailable(id);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { id: null, problem: problemOf(error, { status: 500, debug }) };
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const session = (socket, sessionOptions = {}) => {
|
|
274
|
+
const { hub, authorize } = sessionOptions;
|
|
275
|
+
const values = sessionOptions.values ?? {};
|
|
276
|
+
const topics = new Set;
|
|
277
|
+
let closed = false;
|
|
278
|
+
const send = (reply) => {
|
|
279
|
+
try {
|
|
280
|
+
socket.send(JSON.stringify(reply));
|
|
281
|
+
} catch {}
|
|
282
|
+
};
|
|
283
|
+
const subscribe = async (topic, id) => {
|
|
284
|
+
if (!hub)
|
|
285
|
+
return unavailable(id);
|
|
286
|
+
const allowed = patterns.some((pattern) => matches(pattern, topic)) && (authorize ? await authorize(topic, values) : true);
|
|
287
|
+
if (!allowed) {
|
|
288
|
+
return {
|
|
289
|
+
id,
|
|
290
|
+
problem: problem(new WsTopicForbidden({ source: "websocket", details: { topic } }), 403)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (closed)
|
|
294
|
+
return { id, subscribed: topic };
|
|
295
|
+
hub.subscribe(topic, socket);
|
|
296
|
+
topics.add(topic);
|
|
297
|
+
return { id, subscribed: topic };
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
async message(data) {
|
|
301
|
+
if (closed)
|
|
302
|
+
return;
|
|
303
|
+
try {
|
|
304
|
+
const read_ = read(data);
|
|
305
|
+
if ("reply" in read_)
|
|
306
|
+
return send(read_.reply);
|
|
307
|
+
const { request, id } = read_;
|
|
308
|
+
if ("op" in request)
|
|
309
|
+
return send(await call(request, id, values));
|
|
310
|
+
if ("subscribe" in request) {
|
|
311
|
+
return send(await subscribe(request.subscribe, id));
|
|
312
|
+
}
|
|
313
|
+
if (!hub)
|
|
314
|
+
return send(unavailable(id));
|
|
315
|
+
hub.unsubscribe(request.unsubscribe, socket);
|
|
316
|
+
topics.delete(request.unsubscribe);
|
|
317
|
+
return send({ id, unsubscribed: request.unsubscribe });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
send({
|
|
320
|
+
id: null,
|
|
321
|
+
problem: problemOf(error, { status: 500, debug })
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
close() {
|
|
326
|
+
if (closed)
|
|
327
|
+
return;
|
|
328
|
+
closed = true;
|
|
329
|
+
for (const topic of topics)
|
|
330
|
+
hub?.unsubscribe(topic, socket);
|
|
331
|
+
topics.clear();
|
|
332
|
+
},
|
|
333
|
+
get topics() {
|
|
334
|
+
return [...topics];
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
return {
|
|
339
|
+
operations: [...served.values()].map((operation) => Object.freeze({
|
|
340
|
+
id: operation.id,
|
|
341
|
+
...operation.description ? { description: operation.description } : {},
|
|
342
|
+
inputSchema: toOpenAPI(operation.input)
|
|
343
|
+
})),
|
|
344
|
+
handle,
|
|
345
|
+
session
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
var WsStages = stageCatalogOf(websocket);
|
|
350
|
+
var stages = [
|
|
351
|
+
{ name: "websocket", module: "@afora/ws", export: "websocket", kind: "sink" }
|
|
352
|
+
];
|
|
353
|
+
export {
|
|
354
|
+
InvalidWsMessage,
|
|
355
|
+
WsStages,
|
|
356
|
+
WsSubscriptionsUnavailable,
|
|
357
|
+
WsTopicForbidden,
|
|
358
|
+
stages,
|
|
359
|
+
websocket,
|
|
360
|
+
wsHub
|
|
361
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Problem, PublishedEvent } from "@afora/core";
|
|
2
|
+
/** The id a client gives a request, echoed in the reply. */
|
|
3
|
+
export type WsRequestId = string | number;
|
|
4
|
+
/** What a client sends: a call of an operation, or a (un)subscription to a topic. */
|
|
5
|
+
export type WsRequest = {
|
|
6
|
+
readonly id?: WsRequestId;
|
|
7
|
+
/** The operation id: `"Posts.create"`. */
|
|
8
|
+
readonly op: string;
|
|
9
|
+
readonly input?: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
readonly id?: WsRequestId;
|
|
12
|
+
readonly subscribe: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly id?: WsRequestId;
|
|
15
|
+
readonly unsubscribe: string;
|
|
16
|
+
};
|
|
17
|
+
/** What the server answers to one request; `id` is the request's, or `null` without one. */
|
|
18
|
+
export type WsReply = {
|
|
19
|
+
readonly id: WsRequestId | null;
|
|
20
|
+
readonly value: unknown;
|
|
21
|
+
} | {
|
|
22
|
+
readonly id: WsRequestId | null;
|
|
23
|
+
readonly problem: Problem;
|
|
24
|
+
} | {
|
|
25
|
+
readonly id: WsRequestId | null;
|
|
26
|
+
readonly subscribed: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly id: WsRequestId | null;
|
|
29
|
+
readonly unsubscribed: string;
|
|
30
|
+
};
|
|
31
|
+
/** What the server sends to the subscribers of a topic when an event is published. */
|
|
32
|
+
export interface WsPush {
|
|
33
|
+
readonly topic: string;
|
|
34
|
+
readonly event: PublishedEvent;
|
|
35
|
+
}
|
|
36
|
+
/** A frame that cannot be read: why, and the id when the frame had a readable one. */
|
|
37
|
+
export interface WsMessageFailure {
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
readonly id: WsRequestId | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `data` read as a request: text (or UTF-8 bytes) of a JSON object with an
|
|
43
|
+
* optional `id` and exactly one of `op`, `subscribe` and `unsubscribe`, or
|
|
44
|
+
* already such an object. Never throws.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseWsMessage(data: string | ArrayBuffer | Uint8Array | object, maxBytes: number): {
|
|
47
|
+
readonly request: WsRequest;
|
|
48
|
+
} | {
|
|
49
|
+
readonly failure: WsMessageFailure;
|
|
50
|
+
};
|
package/dist/sink.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { WsHub, WsSocket } from "./hub.js";
|
|
2
|
+
import { type WsReply } from "./protocol.js";
|
|
3
|
+
/** The options of the `websocket` sink. JSON, so the stage can live in an `afora.json`. */
|
|
4
|
+
export interface WebSocketOptions {
|
|
5
|
+
/** Operation ids to serve (`"Posts.create"`); every operation when omitted. */
|
|
6
|
+
readonly include?: readonly string[];
|
|
7
|
+
/**
|
|
8
|
+
* The context of an operation, by key: `{ token: "authorization" }` fills
|
|
9
|
+
* `context.token` from the value named `authorization` of the session (or
|
|
10
|
+
* of `handle`). A missing value leaves the key out.
|
|
11
|
+
*/
|
|
12
|
+
readonly context?: {
|
|
13
|
+
readonly [contextKey: string]: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The topics a session may subscribe to: names or patterns where `*` is
|
|
17
|
+
* one segment between `/` (`"posts/*"`). Without it, every subscription
|
|
18
|
+
* is refused.
|
|
19
|
+
*/
|
|
20
|
+
readonly subscriptions?: {
|
|
21
|
+
readonly topics: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
/** The largest message read, in bytes. 1 MiB by default. */
|
|
24
|
+
readonly maxMessageBytes?: number;
|
|
25
|
+
/** Adds the `trace` to problems and the real `detail` to 5xx ones. Off by default. */
|
|
26
|
+
readonly debug?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Text values the context is read from: headers of the upgrade, a token, a user id. */
|
|
29
|
+
export type WsValues = Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** What a session is opened with. */
|
|
31
|
+
export interface WsSessionOptions {
|
|
32
|
+
/** The values the `context` option reads, e.g. from the headers of the upgrade request. */
|
|
33
|
+
readonly values?: WsValues;
|
|
34
|
+
/** Where subscriptions go; without it, a subscription is a 501 problem. */
|
|
35
|
+
readonly hub?: WsHub;
|
|
36
|
+
/**
|
|
37
|
+
* A check after the topics of the options, such as ownership: a topic it
|
|
38
|
+
* refuses is a 403 problem. All topics the options allow by default.
|
|
39
|
+
*/
|
|
40
|
+
readonly authorize?: (topic: string, values: WsValues) => boolean | Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
/** One connection: what to do with each message, and when it closes. */
|
|
43
|
+
export interface WsSession {
|
|
44
|
+
/** Reads a message and sends the reply to the socket; never throws. */
|
|
45
|
+
message(data: string | ArrayBuffer | Uint8Array): Promise<void>;
|
|
46
|
+
/** Leaves every topic; later messages are ignored. */
|
|
47
|
+
close(): void;
|
|
48
|
+
/** The topics the session is subscribed to. */
|
|
49
|
+
readonly topics: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
/** One operation the endpoint serves. */
|
|
52
|
+
export interface WsOperation {
|
|
53
|
+
/** The operation id, what `op` names. */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly description?: string;
|
|
56
|
+
/** JSON Schema of the input. */
|
|
57
|
+
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
58
|
+
}
|
|
59
|
+
/** What the `websocket` sink builds. */
|
|
60
|
+
export interface WebSocketEndpoint {
|
|
61
|
+
readonly operations: readonly WsOperation[];
|
|
62
|
+
/**
|
|
63
|
+
* The reply to one message, with no connection: a call of an operation.
|
|
64
|
+
* A subscription is a 501 problem here. Never throws.
|
|
65
|
+
*/
|
|
66
|
+
handle(message: string | ArrayBuffer | Uint8Array | object, extra?: {
|
|
67
|
+
readonly values?: WsValues;
|
|
68
|
+
}): Promise<WsReply>;
|
|
69
|
+
/** A session for one socket, with subscriptions when given a hub. */
|
|
70
|
+
session(socket: WsSocket, options?: WsSessionOptions): WsSession;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Serves the operations over WebSocket messages, on any runtime: the sink
|
|
74
|
+
* builds an endpoint, and a few lines of the runtime's WebSocket feed it
|
|
75
|
+
* (see the README for Bun, Hono and Workers).
|
|
76
|
+
*
|
|
77
|
+
* - A request is `{ id?, op, input? }`, with `op` an operation id. The reply
|
|
78
|
+
* is `{ id, value }` (`outputValueOf`: sensitive values masked) or
|
|
79
|
+
* `{ id, problem }`, the `problemOf` of the error with the status of
|
|
80
|
+
* `statusOf` (the output `statuses` first); invalid input is a 400 with
|
|
81
|
+
* `issues`, an unknown `op` a 404, a message that is not a frame a 400
|
|
82
|
+
* `InvalidWsMessage`. `id` is the request's, or `null`.
|
|
83
|
+
* - The context comes from the values of the session through the `context`
|
|
84
|
+
* option; richer rules, such as authentication, are a `transform` stage.
|
|
85
|
+
* - In a session with a hub, `{ id?, subscribe: topic }` subscribes the
|
|
86
|
+
* socket (`{ id, subscribed }`) when `subscriptions.topics` and
|
|
87
|
+
* `authorize` allow it (a 403 `WsTopicForbidden` otherwise), and
|
|
88
|
+
* `{ id?, unsubscribe }` leaves the topic. The hub then sends
|
|
89
|
+
* `{ topic, event }` for each event published to the topic.
|
|
90
|
+
*/
|
|
91
|
+
export declare const websocket: import("@afora/core").SinkFactory<WebSocketEndpoint, WebSocketOptions>;
|
|
92
|
+
/** The runtime catalog of this package: the stage name `websocket` resolves to the sink. */
|
|
93
|
+
export declare const WsStages: import("@afora/core").IStageCatalog<Readonly<Record<string, import("@afora/core").StageFactory>>>;
|
|
94
|
+
/** The catalog entries codegen reads (`--stages @afora/ws`): `{ name, module, export, kind }`. */
|
|
95
|
+
export declare const stages: readonly [{
|
|
96
|
+
readonly name: "websocket";
|
|
97
|
+
readonly module: "@afora/ws";
|
|
98
|
+
readonly export: "websocket";
|
|
99
|
+
readonly kind: "sink";
|
|
100
|
+
}];
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@afora/ws",
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"description": "A WebSocket sink for afora outputs: operations over a JSON protocol, problem+json errors, masked values and topic subscriptions fed by domain events.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"afora",
|
|
7
|
+
"websocket",
|
|
8
|
+
"realtime",
|
|
9
|
+
"events",
|
|
10
|
+
"pubsub"
|
|
11
|
+
],
|
|
12
|
+
"author": "Alan Reis <alanreisanjo@gmail.com> (https://github.com/Hoyasumii)",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://github.com/afora-framework/core/tree/main/packages/ws#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/afora-framework/core.git",
|
|
18
|
+
"directory": "packages/ws"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.js",
|
|
22
|
+
"module": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"bun": ">=1.4.4",
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "bun check",
|
|
45
|
+
"build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external --tsconfig-override tsconfig.build.json && tsc -p tsconfig.build.json && bun ../../scripts/add-dts-extensions.ts dist",
|
|
46
|
+
"test": "bun test"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/bun": "^1.4.4",
|
|
50
|
+
"typebox": "^1.4.3"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@afora/core": ">=0.0.8",
|
|
54
|
+
"typebox": "^1"
|
|
55
|
+
}
|
|
56
|
+
}
|