@relaymesh/relaybus-http 0.0.2
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 +32 -0
- package/package.json +13 -0
- package/src/index.ts +156 -0
- package/test/http.test.ts +75 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# relaybus-http (TypeScript)
|
|
2
|
+
|
|
3
|
+
HTTP publisher and subscriber utilities for Relaybus.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @relaymesh/relaybus-http
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { HttpPublisher, HttpSubscriber } from "@relaymesh/relaybus-http";
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const subscriber = new HttpSubscriber({
|
|
18
|
+
onMessage: (msg) => console.log(msg.topic, msg.payload.toString())
|
|
19
|
+
});
|
|
20
|
+
await subscriber.listen({ port: 8088 });
|
|
21
|
+
|
|
22
|
+
const publisher = HttpPublisher.connect({
|
|
23
|
+
endpoint: "http://localhost:8088/{topic}"
|
|
24
|
+
});
|
|
25
|
+
await publisher.publish("relaybus.demo", {
|
|
26
|
+
topic: "relaybus.demo",
|
|
27
|
+
payload: Buffer.from("hello")
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
main().catch(console.error);
|
|
32
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relaymesh/relaybus-http",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -p tsconfig.json",
|
|
8
|
+
"test": "vitest run"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@relaymesh/relaybus-core": "workspace:*"
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "@relaybus/relaybus-core";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
export type HttpRequest = {
|
|
5
|
+
url: string;
|
|
6
|
+
method: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body: Buffer;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type HttpResponse = {
|
|
12
|
+
status: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type HttpDoer = (req: HttpRequest) => Promise<HttpResponse> | HttpResponse;
|
|
16
|
+
|
|
17
|
+
export type HttpPublisherConfig = {
|
|
18
|
+
endpoint: string;
|
|
19
|
+
doer: HttpDoer;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
idempotencyHeader?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type HttpPublisherConnectConfig = {
|
|
25
|
+
endpoint: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
idempotencyHeader?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class HttpPublisher {
|
|
31
|
+
private readonly endpoint: string;
|
|
32
|
+
private readonly doer: HttpDoer;
|
|
33
|
+
private readonly headers: Record<string, string>;
|
|
34
|
+
private readonly idempotencyHeader: string;
|
|
35
|
+
|
|
36
|
+
constructor(config: HttpPublisherConfig) {
|
|
37
|
+
if (!config.endpoint) {
|
|
38
|
+
throw new Error("endpoint is required");
|
|
39
|
+
}
|
|
40
|
+
if (!config.doer) {
|
|
41
|
+
throw new Error("doer is required");
|
|
42
|
+
}
|
|
43
|
+
this.endpoint = config.endpoint;
|
|
44
|
+
this.doer = config.doer;
|
|
45
|
+
this.headers = config.headers ?? {};
|
|
46
|
+
this.idempotencyHeader = config.idempotencyHeader ?? "Idempotency-Key";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static connect(config: HttpPublisherConnectConfig): HttpPublisher {
|
|
50
|
+
if (!globalThis.fetch) {
|
|
51
|
+
throw new Error("fetch is not available");
|
|
52
|
+
}
|
|
53
|
+
return new HttpPublisher({
|
|
54
|
+
endpoint: config.endpoint,
|
|
55
|
+
headers: config.headers,
|
|
56
|
+
idempotencyHeader: config.idempotencyHeader,
|
|
57
|
+
doer: async (req) => {
|
|
58
|
+
const response = await fetch(req.url, {
|
|
59
|
+
method: req.method,
|
|
60
|
+
headers: req.headers,
|
|
61
|
+
body: new Uint8Array(req.body)
|
|
62
|
+
});
|
|
63
|
+
return { status: response.status };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async publish(topic: string, message: OutgoingMessage): Promise<void> {
|
|
69
|
+
const resolved = resolveTopic(topic, message.topic);
|
|
70
|
+
const payload = encodeEnvelope({ ...message, topic: resolved });
|
|
71
|
+
|
|
72
|
+
const req: HttpRequest = {
|
|
73
|
+
url: buildEndpoint(this.endpoint, resolved),
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
...this.headers
|
|
78
|
+
},
|
|
79
|
+
body: payload
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (message.id) {
|
|
83
|
+
req.headers[this.idempotencyHeader] = message.id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const response = await Promise.resolve(this.doer(req));
|
|
87
|
+
if (response.status < 200 || response.status >= 300) {
|
|
88
|
+
throw new Error(`http status ${response.status}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type HttpSubscriberConfig = {
|
|
94
|
+
onMessage: (msg: DecodedMessage) => void | Promise<void>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type HttpSubscriberListenConfig = {
|
|
98
|
+
port: number;
|
|
99
|
+
host?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export class HttpSubscriber {
|
|
103
|
+
private readonly onMessage: (msg: DecodedMessage) => void | Promise<void>;
|
|
104
|
+
|
|
105
|
+
constructor(config: HttpSubscriberConfig) {
|
|
106
|
+
this.onMessage = config.onMessage;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async handle(body: Buffer | string): Promise<void> {
|
|
110
|
+
const decoded = decodeEnvelope(body);
|
|
111
|
+
await this.onMessage(decoded);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async listen(config: HttpSubscriberListenConfig): Promise<http.Server> {
|
|
115
|
+
const server = http.createServer((req, res) => {
|
|
116
|
+
const chunks: Buffer[] = [];
|
|
117
|
+
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
118
|
+
req.on("end", async () => {
|
|
119
|
+
try {
|
|
120
|
+
await this.handle(Buffer.concat(chunks));
|
|
121
|
+
res.statusCode = 204;
|
|
122
|
+
} catch {
|
|
123
|
+
res.statusCode = 400;
|
|
124
|
+
}
|
|
125
|
+
res.end();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await new Promise<void>((resolve, reject) => {
|
|
130
|
+
server.once("error", reject);
|
|
131
|
+
server.listen(config.port, config.host, () => resolve());
|
|
132
|
+
});
|
|
133
|
+
return server;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveTopic(argumentTopic: string, messageTopic?: string): string {
|
|
138
|
+
const topic = messageTopic && messageTopic.length > 0 ? messageTopic : argumentTopic;
|
|
139
|
+
if (!topic) {
|
|
140
|
+
throw new Error("topic is required");
|
|
141
|
+
}
|
|
142
|
+
if (argumentTopic && messageTopic && argumentTopic !== messageTopic) {
|
|
143
|
+
throw new Error(`topic mismatch: ${messageTopic} vs ${argumentTopic}`);
|
|
144
|
+
}
|
|
145
|
+
return topic;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildEndpoint(endpoint: string, topic: string): string {
|
|
149
|
+
if (endpoint.includes("{topic}")) {
|
|
150
|
+
return endpoint.split("{topic}").join(encodeURIComponent(topic));
|
|
151
|
+
}
|
|
152
|
+
if (!topic) {
|
|
153
|
+
return endpoint;
|
|
154
|
+
}
|
|
155
|
+
return endpoint.replace(/\/$/, "") + "/" + encodeURIComponent(topic);
|
|
156
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpPublisher, HttpSubscriber } from "../src/index";
|
|
3
|
+
import { decodeEnvelope } from "@relaybus/relaybus-core";
|
|
4
|
+
|
|
5
|
+
describe("HttpPublisher", () => {
|
|
6
|
+
it("posts encoded envelope", async () => {
|
|
7
|
+
const calls: any[] = [];
|
|
8
|
+
const publisher = new HttpPublisher({
|
|
9
|
+
endpoint: "https://example.test/{topic}",
|
|
10
|
+
doer: async (req) => {
|
|
11
|
+
calls.push(req);
|
|
12
|
+
return { status: 204 };
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
await publisher.publish("alpha", {
|
|
17
|
+
id: "id-1",
|
|
18
|
+
topic: "alpha",
|
|
19
|
+
payload: Buffer.from("hi")
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(calls).toHaveLength(1);
|
|
23
|
+
const call = calls[0];
|
|
24
|
+
expect(call.url).toBe("https://example.test/alpha");
|
|
25
|
+
expect(call.headers["Content-Type"]).toBe("application/json");
|
|
26
|
+
expect(call.headers["Idempotency-Key"]).toBe("id-1");
|
|
27
|
+
const decoded = decodeEnvelope(call.body);
|
|
28
|
+
expect(decoded.id).toBe("id-1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects non-2xx", async () => {
|
|
32
|
+
const publisher = new HttpPublisher({
|
|
33
|
+
endpoint: "https://example.test/{topic}",
|
|
34
|
+
doer: async () => ({ status: 500 })
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await expect(
|
|
38
|
+
publisher.publish("alpha", { topic: "alpha", payload: Buffer.from("hi") })
|
|
39
|
+
).rejects.toThrow(/http status 500/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("HttpSubscriber", () => {
|
|
44
|
+
it("decodes and forwards", async () => {
|
|
45
|
+
let seen = "";
|
|
46
|
+
const subscriber = new HttpSubscriber({
|
|
47
|
+
onMessage: (msg) => {
|
|
48
|
+
seen = msg.id;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await subscriber.handle(
|
|
53
|
+
Buffer.from(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
v: "v1",
|
|
56
|
+
id: "id",
|
|
57
|
+
topic: "alpha",
|
|
58
|
+
ts: "2024-01-01T00:00:00Z",
|
|
59
|
+
content_type: "text/plain",
|
|
60
|
+
payload_b64: "aGVsbG8=",
|
|
61
|
+
meta: {}
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(seen).toBe("id");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects invalid json", async () => {
|
|
70
|
+
const subscriber = new HttpSubscriber({
|
|
71
|
+
onMessage: () => {}
|
|
72
|
+
});
|
|
73
|
+
await expect(subscriber.handle("{")).rejects.toThrow(/invalid json/);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"baseUrl": ".",
|
|
9
|
+
"paths": {
|
|
10
|
+
"@relaybus/relaybus-core": ["../../core/typescript/dist"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|