@hybrid-compute/remote 0.0.1
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/.release-it.json +70 -0
- package/README.md +88 -0
- package/docs/example.md +154 -0
- package/docs/testing.md +132 -0
- package/eslint.config.mjs +3 -0
- package/package.json +54 -0
- package/src/__tests__/remote.spec.ts +176 -0
- package/src/index.ts +145 -0
- package/src/types.ts +38 -0
- package/tsconfig.json +12 -0
package/.release-it.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"git": false,
|
|
3
|
+
"github": {
|
|
4
|
+
"release": true,
|
|
5
|
+
"tokenRef": "GH_TOKEN"
|
|
6
|
+
},
|
|
7
|
+
"npm": {
|
|
8
|
+
"publish": true,
|
|
9
|
+
"skipChecks": true
|
|
10
|
+
},
|
|
11
|
+
"hooks": {
|
|
12
|
+
"after:release": "echo Successfully released ${name} v${version} to ${repo.repository}."
|
|
13
|
+
},
|
|
14
|
+
"plugins": {
|
|
15
|
+
"@release-it/bumper": {
|
|
16
|
+
"out": {
|
|
17
|
+
"file": "package.json",
|
|
18
|
+
"path": ["dependencies.@hybrid-compute/core"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"@release-it/conventional-changelog": {
|
|
22
|
+
"header": "# Changelog",
|
|
23
|
+
"preset": {
|
|
24
|
+
"name": "conventionalcommits",
|
|
25
|
+
"types": [
|
|
26
|
+
{
|
|
27
|
+
"type": "chore",
|
|
28
|
+
"section": "Tasks"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "docs",
|
|
32
|
+
"section": "Documentation"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "feat",
|
|
36
|
+
"section": "Feature"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"type": "fix",
|
|
40
|
+
"section": "Bug"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "perf",
|
|
44
|
+
"section": "Performance change"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "refactor",
|
|
48
|
+
"section": "Refactoring"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "release",
|
|
52
|
+
"section": "Create a release commit",
|
|
53
|
+
"hidden": true
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"type": "style",
|
|
57
|
+
"section": "Markup, white-space, formatting, missing semi-colons...",
|
|
58
|
+
"hidden": true
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "test",
|
|
62
|
+
"section": "Adding missing tests",
|
|
63
|
+
"hidden": true
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"infile": "CHANGELOG.md"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @hybrid-compute/remote
|
|
2
|
+
|
|
3
|
+
[](http://commitizen.github.io/cz-cli/)
|
|
4
|
+
[](http://makeapullrequest.com)
|
|
5
|
+
[](http://semver.org/spec/v2.0.0.html)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
[](https://codecov.io/gh/phun-ky/hybrid-compute)
|
|
13
|
+
[](https://github.com/phun-ky/hybrid-compute/actions/workflows/check.yml)
|
|
14
|
+
|
|
15
|
+
Part of the [`@hybrid-compute`](https://github.com/phun-ky/hybrid-compute)
|
|
16
|
+
monorepo.
|
|
17
|
+
|
|
18
|
+
> See the [main README](https://github.com/phun-ky/hybrid-compute#readme) for
|
|
19
|
+
> full project overview, usage examples, architecture, and contribution
|
|
20
|
+
> guidelines.
|
|
21
|
+
|
|
22
|
+
## API Docs
|
|
23
|
+
|
|
24
|
+
[RemoteCompute API Documentation](https://github.com/phun-ky/hybrid-compute/blob/main/docs/api/remote/src/classes/RemoteCompute.md)
|
|
25
|
+
|
|
26
|
+
## 📦 Package Info
|
|
27
|
+
|
|
28
|
+
This package provides:
|
|
29
|
+
|
|
30
|
+
- A remote compute backend that delegates tasks over HTTP or WebSocket
|
|
31
|
+
- Transport-agnostic JSON message protocol
|
|
32
|
+
- Suitable for offloading work to distributed compute services
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install @hybrid-compute/remote
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createRemoteCompute } from '@hybrid-compute/remote';
|
|
42
|
+
|
|
43
|
+
const remote = createRemoteCompute({
|
|
44
|
+
transport: 'fetch',
|
|
45
|
+
endpoint: '/api/compute'
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
You can see
|
|
50
|
+
[examples here](https://github.com/phun-ky/hybrid-compute/blob/main/docs/api/remote/docs/example.md),
|
|
51
|
+
and a
|
|
52
|
+
[testing guide here](https://github.com/phun-ky/hybrid-compute/blob/main/docs/api/remote/docs/testing.md)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Contributing
|
|
57
|
+
|
|
58
|
+
Want to contribute? Please read the
|
|
59
|
+
[CONTRIBUTING.md](https://github.com/phun-ky/hybrid-compute/blob/main/CONTRIBUTING.md)
|
|
60
|
+
and
|
|
61
|
+
[CODE_OF_CONDUCT.md](https://github.com/phun-ky/hybrid-compute/blob/main/CODE_OF_CONDUCT.md)
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
This project is licensed under the MIT License - see the
|
|
66
|
+
[LICENSE](https://github.com/phun-ky/hybrid-compute/blob/main/LICENSE) file for
|
|
67
|
+
details.
|
|
68
|
+
|
|
69
|
+
## Sponsor me
|
|
70
|
+
|
|
71
|
+
I'm an Open Source evangelist, creating stuff that does not exist yet to help
|
|
72
|
+
get rid of secondary activities and to enhance systems already in place, be it
|
|
73
|
+
documentation, tools or web sites.
|
|
74
|
+
|
|
75
|
+
The sponsorship is an unique opportunity to alleviate more hours for me to
|
|
76
|
+
maintain my projects, create new ones and contribute to the large community
|
|
77
|
+
we're all part of :)
|
|
78
|
+
|
|
79
|
+
[Support me on GitHub Sponsors](https://github.com/sponsors/phun-ky).
|
|
80
|
+
|
|
81
|
+
p.s. **Ukraine is still under brutal Russian invasion. A lot of Ukrainian people
|
|
82
|
+
are hurt, without shelter and need help**. You can help in various ways, for
|
|
83
|
+
instance, directly helping refugees, spreading awareness, putting pressure on
|
|
84
|
+
your local government or companies. You can also support Ukraine by donating
|
|
85
|
+
e.g. to [Red Cross](https://www.icrc.org/en/donate/ukraine),
|
|
86
|
+
[Ukraine humanitarian organisation](https://savelife.in.ua/en/donate-en/#donate-army-card-weekly)
|
|
87
|
+
or
|
|
88
|
+
[donate Ambulances for Ukraine](https://www.gofundme.com/f/help-to-save-the-lives-of-civilians-in-a-war-zone).
|
package/docs/example.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# RemoteCompute Usage Guide
|
|
2
|
+
|
|
3
|
+
This guide explains how to use the `RemoteCompute` backend for remote task
|
|
4
|
+
execution via `fetch` or `WebSocket`, and also illustrates how to implement the
|
|
5
|
+
server side for each transport.
|
|
6
|
+
|
|
7
|
+
## Table of Contents<!-- omit from toc -->
|
|
8
|
+
|
|
9
|
+
- [RemoteCompute Usage Guide](#remotecompute-usage-guide)
|
|
10
|
+
- [Overview](#overview)
|
|
11
|
+
- [Client-Side Usage](#client-side-usage)
|
|
12
|
+
- [1. Using `fetch` Transport](#1-using-fetch-transport)
|
|
13
|
+
- [2. Using `websocket` Transport](#2-using-websocket-transport)
|
|
14
|
+
- [Backend Implementation](#backend-implementation)
|
|
15
|
+
- [1. Backend for Fetch (Node.js/Express)](#1-backend-for-fetch-nodejsexpress)
|
|
16
|
+
- [2. Backend for WebSocket (Node.js/ws)](#2-backend-for-websocket-nodejsws)
|
|
17
|
+
- [Best Practices](#best-practices)
|
|
18
|
+
- [Troubleshooting](#troubleshooting)
|
|
19
|
+
- [References](#references)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
`RemoteCompute` allows clients to send computation tasks to a remote service
|
|
26
|
+
using one of two transport protocols:
|
|
27
|
+
|
|
28
|
+
- `fetch`: HTTP POST requests
|
|
29
|
+
- `websocket`: Persistent WebSocket connection
|
|
30
|
+
|
|
31
|
+
Each task must be known to the client (optionally restricted via `canRunTasks`)
|
|
32
|
+
and supported by the server.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Client-Side Usage
|
|
37
|
+
|
|
38
|
+
### 1. Using `fetch` Transport
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createRemoteCompute } from '@hybrid-compute/remote';
|
|
42
|
+
|
|
43
|
+
const remote = createRemoteCompute({
|
|
44
|
+
transport: 'fetch',
|
|
45
|
+
endpoint: 'https://api.example.com/compute',
|
|
46
|
+
canRunTasks: ['generatePDF']
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await remote.runTask('generatePDF', {
|
|
50
|
+
content: 'Hello, world!'
|
|
51
|
+
});
|
|
52
|
+
console.log(result);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Using `websocket` Transport
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createRemoteCompute } from '@hybrid-compute/remote';
|
|
59
|
+
|
|
60
|
+
const remote = createRemoteCompute({
|
|
61
|
+
transport: 'websocket',
|
|
62
|
+
endpoint: 'wss://api.example.com/ws',
|
|
63
|
+
canRunTasks: ['summarizeText']
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const result = await remote.runTask('summarizeText', {
|
|
67
|
+
text: 'This is a long text...'
|
|
68
|
+
});
|
|
69
|
+
console.log(result);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Backend Implementation
|
|
75
|
+
|
|
76
|
+
### 1. Backend for Fetch (Node.js/Express)
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import express from 'express';
|
|
80
|
+
const app = express();
|
|
81
|
+
app.use(express.json());
|
|
82
|
+
|
|
83
|
+
const handlers = {
|
|
84
|
+
generatePDF: async ({ content }) => {
|
|
85
|
+
// simulate PDF generation
|
|
86
|
+
return `PDF with content: ${content}`;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
app.post('/compute', async (req, res) => {
|
|
91
|
+
const { task, input } = req.body;
|
|
92
|
+
try {
|
|
93
|
+
if (!handlers[task]) throw new Error('Unknown task');
|
|
94
|
+
const result = await handlers[task](input);
|
|
95
|
+
res.json({ result });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.json({ error: error.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.listen(3000);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2. Backend for WebSocket (Node.js/ws)
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { WebSocketServer } from 'ws';
|
|
108
|
+
|
|
109
|
+
const wss = new WebSocketServer({ port: 8080 });
|
|
110
|
+
|
|
111
|
+
const handlers = {
|
|
112
|
+
summarizeText: async ({ text }) => {
|
|
113
|
+
return `Summary: ${text.slice(0, 20)}...`;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
wss.on('connection', (ws) => {
|
|
118
|
+
ws.on('message', async (message) => {
|
|
119
|
+
const { task, input, id } = JSON.parse(message.toString());
|
|
120
|
+
try {
|
|
121
|
+
if (!handlers[task]) throw new Error('Unknown task');
|
|
122
|
+
const result = await handlers[task](input);
|
|
123
|
+
ws.send(JSON.stringify({ id, result }));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
ws.send(JSON.stringify({ id, error: error.message }));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Best Practices
|
|
134
|
+
|
|
135
|
+
- Validate task names on both ends
|
|
136
|
+
- Enforce authentication and authorization for remote compute endpoints
|
|
137
|
+
- In WebSocket mode, monitor socket health and auto-reconnect if needed
|
|
138
|
+
- Always wrap remote responses with task `id` for proper correlation
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Troubleshooting
|
|
143
|
+
|
|
144
|
+
| Issue | Solution |
|
|
145
|
+
| ------------------------------- | --------------------------------------------------------------- |
|
|
146
|
+
| `WebSocket not connected` error | Ensure WebSocket is open before sending messages |
|
|
147
|
+
| Task not found | Confirm the task name is registered and listed in `canRunTasks` |
|
|
148
|
+
| JSON parse error | Ensure backend and client speak the same message protocol |
|
|
149
|
+
|
|
150
|
+
## References
|
|
151
|
+
|
|
152
|
+
- [MDN: fetch API](https://developer.mozilla.org/en-US/docs/Web/API/fetch)
|
|
153
|
+
- [MDN: WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
|
154
|
+
- [WebSocket on NPM](https://www.npmjs.com/package/ws)
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# RemoteCompute Test Harness & CURL Examples
|
|
2
|
+
|
|
3
|
+
This guide provides a simple test harness and `curl` commands to validate that
|
|
4
|
+
your remote backend endpoints (both fetch and websocket) are working correctly.
|
|
5
|
+
|
|
6
|
+
## Table of Contents<!-- omit from toc -->
|
|
7
|
+
|
|
8
|
+
- [RemoteCompute Test Harness \& CURL Examples](#remotecompute-test-harness--curl-examples)
|
|
9
|
+
- [HTTP `fetch` Test Harness (Node.js)](#http-fetch-test-harness-nodejs)
|
|
10
|
+
- [File: `test-fetch.js`](#file-test-fetchjs)
|
|
11
|
+
- [CURL Test: `fetch`](#curl-test-fetch)
|
|
12
|
+
- [WebSocket Test Harness (Node.js)](#websocket-test-harness-nodejs)
|
|
13
|
+
- [File: `test-websocket.js`](#file-test-websocketjs)
|
|
14
|
+
- [WebSocket Manual Testing (via `wscat`)](#websocket-manual-testing-via-wscat)
|
|
15
|
+
- [What to Verify](#what-to-verify)
|
|
16
|
+
- [Security Tip](#security-tip)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## HTTP `fetch` Test Harness (Node.js)
|
|
21
|
+
|
|
22
|
+
### File: `test-fetch.js`
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
const taskName = 'generatePDF';
|
|
26
|
+
const input = { content: 'Hello, PDF World!' };
|
|
27
|
+
|
|
28
|
+
async function run() {
|
|
29
|
+
const res = await fetch('http://localhost:3000/compute', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ task: taskName, input })
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const json = await res.json();
|
|
36
|
+
console.log('Response:', json);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
run().catch(console.error);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### CURL Test: `fetch`
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
curl -X POST http://localhost:3000/compute \
|
|
46
|
+
-H "Content-Type: application/json" \
|
|
47
|
+
-d '{"task": "generatePDF", "input": { "content": "Hello from CURL" }}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Expected output:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "result": "PDF with content: Hello from CURL" }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## WebSocket Test Harness (Node.js)
|
|
57
|
+
|
|
58
|
+
### File: `test-websocket.js`
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import WebSocket from 'ws';
|
|
62
|
+
|
|
63
|
+
const socket = new WebSocket('ws://localhost:8080');
|
|
64
|
+
const taskId = 1;
|
|
65
|
+
|
|
66
|
+
socket.onopen = () => {
|
|
67
|
+
socket.send(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
task: 'summarizeText',
|
|
70
|
+
input: {
|
|
71
|
+
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
|
72
|
+
},
|
|
73
|
+
id: taskId
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
socket.onmessage = (event) => {
|
|
79
|
+
const data = JSON.parse(event.data);
|
|
80
|
+
console.log('WS response:', data);
|
|
81
|
+
socket.close();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
socket.onerror = (err) => {
|
|
85
|
+
console.error('WebSocket error:', err);
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### WebSocket Manual Testing (via `wscat`)
|
|
90
|
+
|
|
91
|
+
You can use `wscat` from the terminal:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install -g wscat
|
|
95
|
+
wscat -c ws://localhost:8080
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Once connected, send this JSON payload:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"task": "summarizeText",
|
|
103
|
+
"input": { "text": "WebSocket test with a longer text to summarize" },
|
|
104
|
+
"id": 42
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Expected response:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"id": 42,
|
|
113
|
+
"result": "Summary: WebSocket test with..."
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## What to Verify
|
|
118
|
+
|
|
119
|
+
| Test | Expected |
|
|
120
|
+
| ----------------- | ----------------------------------------------- |
|
|
121
|
+
| fetch POST | JSON response with `"result"` or `"error"` |
|
|
122
|
+
| WebSocket send | JSON message with matching `"id"` and result |
|
|
123
|
+
| Unregistered task | `"error": "Unknown task"` |
|
|
124
|
+
| Missing input | backend should throw or return error gracefully |
|
|
125
|
+
|
|
126
|
+
## Security Tip
|
|
127
|
+
|
|
128
|
+
In production, always validate:
|
|
129
|
+
|
|
130
|
+
- Origin and authentication headers
|
|
131
|
+
- Rate limits
|
|
132
|
+
- Schema of incoming requests (e.g. using Zod or Yup)
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hybrid-compute/remote",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Remote compute backend using fetch or WebSocket transport for distributed task execution.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"remote",
|
|
7
|
+
"compute",
|
|
8
|
+
"websocket",
|
|
9
|
+
"fetch",
|
|
10
|
+
"transport",
|
|
11
|
+
"distributed",
|
|
12
|
+
"json-rpc",
|
|
13
|
+
"http",
|
|
14
|
+
"cloud",
|
|
15
|
+
"task"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://phun-ky.net/projects/hybrid-compute",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/phun-ky/hybrid-compute/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/phun-ky/hybrid-compute.git"
|
|
24
|
+
},
|
|
25
|
+
"funding": "https://github.com/phun-ky/hybrid-compute?sponsor=1",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Alexander Vassbotn Røyne-Helgesen <alexander@phun-ky.net>",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"release": "release-it",
|
|
33
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
34
|
+
"test": "tsx --test **/__tests__/**/*.spec.ts",
|
|
35
|
+
"pretest:ci": "rm -rf coverage && mkdir -p coverage",
|
|
36
|
+
"test:ci": "glob -c \"node --import tsx --test --no-warnings --experimental-test-coverage --test-reporter=cobertura --test-reporter-destination=coverage/cobertura-coverage.xml --test-reporter=spec --test-reporter-destination=stdout\" \"**/__tests__/**/*.spec.ts\""
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@hybrid-compute/core": "0.0.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"eslint": "^9.27.0",
|
|
43
|
+
"eslint-config-phun-ky": "^1.0.3",
|
|
44
|
+
"prettier": "^3.5.3",
|
|
45
|
+
"typescript": "^5.8.3"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.0.0",
|
|
49
|
+
"npm": ">=10.8.2"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import test, { describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRemoteCompute, RemoteCompute } from '..';
|
|
4
|
+
globalThis.fetch = async (
|
|
5
|
+
input: RequestInfo | URL,
|
|
6
|
+
init?: RequestInit
|
|
7
|
+
): Promise<Response> => {
|
|
8
|
+
const body = JSON.parse((init?.body as string) ?? '{}');
|
|
9
|
+
|
|
10
|
+
const isError = body.task === 'fail';
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
ok: !isError,
|
|
14
|
+
status: isError ? 400 : 200,
|
|
15
|
+
headers: new Headers(),
|
|
16
|
+
redirected: false,
|
|
17
|
+
statusText: isError ? 'Bad Request' : 'OK',
|
|
18
|
+
type: 'basic',
|
|
19
|
+
url: String(input),
|
|
20
|
+
clone: function () {
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
body: null,
|
|
24
|
+
bodyUsed: true,
|
|
25
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
26
|
+
blob: async () => new Blob(),
|
|
27
|
+
formData: async () => new FormData(),
|
|
28
|
+
text: async () =>
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
isError
|
|
31
|
+
? { error: 'Task failed' }
|
|
32
|
+
: { result: `Result for ${body.task}` }
|
|
33
|
+
),
|
|
34
|
+
json: async () =>
|
|
35
|
+
isError ? { error: 'Task failed' } : { result: `Result for ${body.task}` }
|
|
36
|
+
} as Response;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('RemoteCompute', () => {
|
|
40
|
+
test('fetch transport returns result from remote', async () => {
|
|
41
|
+
const compute = new RemoteCompute({
|
|
42
|
+
transport: 'fetch',
|
|
43
|
+
endpoint: 'https://api.example.com/compute'
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await compute.runTask('echo', { message: 'hi' });
|
|
47
|
+
assert.equal(result, 'Result for echo');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('fetch transport throws on error response', async () => {
|
|
51
|
+
const compute = new RemoteCompute({
|
|
52
|
+
transport: 'fetch',
|
|
53
|
+
endpoint: 'https://api.example.com/compute'
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await assert.rejects(() => compute.runTask('fail', {}), /Task failed/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('canRun returns true if task is allowed', () => {
|
|
60
|
+
const compute = new RemoteCompute({
|
|
61
|
+
transport: 'fetch',
|
|
62
|
+
endpoint: '/compute',
|
|
63
|
+
canRunTasks: ['foo', 'bar']
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(compute.canRun('foo'), true);
|
|
67
|
+
assert.equal(compute.canRun('baz'), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('canRun returns true if canRunTasks is undefined', () => {
|
|
71
|
+
const compute = new RemoteCompute({
|
|
72
|
+
transport: 'fetch',
|
|
73
|
+
endpoint: '/compute'
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(compute.canRun('anything'), true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test(
|
|
80
|
+
'WebSocket transport sends and resolves a message',
|
|
81
|
+
{ timeout: 200 },
|
|
82
|
+
async () => {
|
|
83
|
+
const OriginalWebSocket = globalThis.WebSocket;
|
|
84
|
+
let sentData = '';
|
|
85
|
+
|
|
86
|
+
class MockWebSocket {
|
|
87
|
+
public readyState = WebSocket.OPEN;
|
|
88
|
+
public onmessage: ((event: MessageEvent) => void) | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(public url: string) {}
|
|
91
|
+
|
|
92
|
+
send(data: string) {
|
|
93
|
+
sentData = data;
|
|
94
|
+
const { id } = JSON.parse(data);
|
|
95
|
+
const fakeResponse = { id, result: 'websocket-result' };
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
this.onmessage?.({
|
|
98
|
+
data: JSON.stringify(fakeResponse)
|
|
99
|
+
} as MessageEvent);
|
|
100
|
+
}, 10);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// @ts-expect-error override native WebSocket
|
|
105
|
+
globalThis.WebSocket = MockWebSocket;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const compute = new RemoteCompute({
|
|
109
|
+
transport: 'websocket',
|
|
110
|
+
endpoint: 'wss://example.com/ws'
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Wait a tick before calling runTask (mimics real socket delay)
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
115
|
+
|
|
116
|
+
const result = await compute.runTask('test', { foo: 1 });
|
|
117
|
+
assert.equal(result, 'websocket-result');
|
|
118
|
+
} finally {
|
|
119
|
+
globalThis.WebSocket = OriginalWebSocket;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
test('WebSocket rejects if not connected', async () => {
|
|
125
|
+
const OriginalWebSocket = globalThis.WebSocket;
|
|
126
|
+
|
|
127
|
+
class MockWebSocket {
|
|
128
|
+
public readyState = 3; // WebSocket.CLOSED
|
|
129
|
+
public onmessage: ((event: MessageEvent) => void) | null = null;
|
|
130
|
+
|
|
131
|
+
constructor(public url: string) {
|
|
132
|
+
console.log('[MOCK] MockWebSocket constructor called');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
send() {
|
|
136
|
+
console.log('[MOCK] send() should not be called');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// @ts-expect-error override
|
|
141
|
+
globalThis.WebSocket = MockWebSocket;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const compute = new RemoteCompute({
|
|
145
|
+
transport: 'websocket',
|
|
146
|
+
endpoint: 'wss://example.com/ws'
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const resultPromise = compute.runTask('any', {});
|
|
150
|
+
await assert.rejects(resultPromise, /WebSocket not connected/);
|
|
151
|
+
} finally {
|
|
152
|
+
globalThis.WebSocket = OriginalWebSocket;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('runTask() manually rejects for testing', async () => {
|
|
157
|
+
const compute = {
|
|
158
|
+
runTask: () => Promise.reject(new Error('WebSocket not connected'))
|
|
159
|
+
} as unknown as RemoteCompute;
|
|
160
|
+
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
() => compute.runTask('x', {}),
|
|
163
|
+
/WebSocket not connected/
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('createRemoteCompute', () => {
|
|
168
|
+
test('returns instance of RemoteCompute', () => {
|
|
169
|
+
const instance = createRemoteCompute({
|
|
170
|
+
transport: 'fetch',
|
|
171
|
+
endpoint: '/compute'
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.ok(instance instanceof RemoteCompute);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { ComputeBackendInterface } from '@hybrid-compute/core';
|
|
3
|
+
|
|
4
|
+
import { RemoteComputeOptionsInterface, RemoteTransportType } from './types.js';
|
|
5
|
+
|
|
6
|
+
export * from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* RemoteCompute is a backend that delegates compute tasks to a remote API
|
|
10
|
+
* using either HTTP requests (fetch) or a persistent WebSocket connection.
|
|
11
|
+
*
|
|
12
|
+
* It supports bidirectional communication, which is useful for low-latency
|
|
13
|
+
* or streaming scenarios using WebSocket, or traditional stateless interaction
|
|
14
|
+
* using fetch.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* WebSocket-based transport allows concurrent request handling via an internal
|
|
18
|
+
* request/response map using `id`. This is useful when running multiple tasks in parallel.
|
|
19
|
+
*
|
|
20
|
+
* Fetch transport is simpler and more interoperable with typical REST APIs.
|
|
21
|
+
*
|
|
22
|
+
* @example Fetch transport
|
|
23
|
+
* ```ts
|
|
24
|
+
* const remote = new RemoteCompute({
|
|
25
|
+
* transport: 'fetch',
|
|
26
|
+
* endpoint: 'https://api.example.com/compute',
|
|
27
|
+
* canRunTasks: ['translateText']
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* const result = await remote.runTask('translateText', { text: 'hello' });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example WebSocket transport
|
|
34
|
+
* ```ts
|
|
35
|
+
* const remote = new RemoteCompute({
|
|
36
|
+
* transport: 'websocket',
|
|
37
|
+
* endpoint: 'wss://api.example.com/ws',
|
|
38
|
+
* canRunTasks: ['analyzeSentiment']
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* const result = await remote.runTask('analyzeSentiment', { text: 'It works!' });
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/fetch
|
|
45
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
|
|
46
|
+
*/
|
|
47
|
+
export class RemoteCompute implements ComputeBackendInterface {
|
|
48
|
+
private transport: RemoteTransportType;
|
|
49
|
+
private endpoint: string;
|
|
50
|
+
private canRunSet: Set<string>;
|
|
51
|
+
private socket?: WebSocket;
|
|
52
|
+
private pending = new Map<
|
|
53
|
+
number,
|
|
54
|
+
{
|
|
55
|
+
resolve: (value: any | PromiseLike<any>) => void;
|
|
56
|
+
reject: (value: any | PromiseLike<any>) => void;
|
|
57
|
+
}
|
|
58
|
+
>();
|
|
59
|
+
private nextId = 1;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Initializes the remote compute backend.
|
|
63
|
+
*
|
|
64
|
+
* @param options - Transport type and endpoint configuration.
|
|
65
|
+
*/
|
|
66
|
+
constructor(options: RemoteComputeOptionsInterface) {
|
|
67
|
+
this.transport = options.transport;
|
|
68
|
+
this.endpoint = options.endpoint;
|
|
69
|
+
this.canRunSet = new Set(options.canRunTasks ?? []);
|
|
70
|
+
|
|
71
|
+
if (this.transport === 'websocket') {
|
|
72
|
+
this.socket = new WebSocket(this.endpoint);
|
|
73
|
+
|
|
74
|
+
this.socket.onmessage = (event: MessageEvent) => {
|
|
75
|
+
const { id, result, error } = JSON.parse(event.data);
|
|
76
|
+
|
|
77
|
+
if (error) this.pending.get(id)?.reject(error);
|
|
78
|
+
else this.pending.get(id)?.resolve(result);
|
|
79
|
+
|
|
80
|
+
this.pending.delete(id);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Determines if this backend is allowed to handle the given task.
|
|
87
|
+
*
|
|
88
|
+
* @param taskName - Name of the task.
|
|
89
|
+
* @returns `true` if task is permitted, or if no restrictions are set.
|
|
90
|
+
*/
|
|
91
|
+
canRun(taskName: string): boolean {
|
|
92
|
+
return this.canRunSet.size === 0 || this.canRunSet.has(taskName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Executes the specified task using remote communication.
|
|
97
|
+
*
|
|
98
|
+
* @typeParam Input - The input data structure expected by the task.
|
|
99
|
+
* @typeParam Output - The output structure returned by the task.
|
|
100
|
+
*
|
|
101
|
+
* @param taskName - Name of the remote task.
|
|
102
|
+
* @param input - Input data to send.
|
|
103
|
+
* @returns A promise resolving to the result from the server.
|
|
104
|
+
*/
|
|
105
|
+
async runTask<Input, Output>(
|
|
106
|
+
taskName: string,
|
|
107
|
+
input: Input
|
|
108
|
+
): Promise<Output> {
|
|
109
|
+
const id = this.nextId++;
|
|
110
|
+
|
|
111
|
+
if (this.transport === 'fetch') {
|
|
112
|
+
const response = await fetch(this.endpoint, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ task: taskName, input })
|
|
116
|
+
});
|
|
117
|
+
const { result, error } = await response.json();
|
|
118
|
+
|
|
119
|
+
if (error) throw new Error(error);
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return new Promise<Output>((resolve, reject) => {
|
|
125
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
126
|
+
reject(new Error('WebSocket not connected'));
|
|
127
|
+
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.pending.set(id, { resolve, reject });
|
|
132
|
+
this.socket.send(JSON.stringify({ task: taskName, input, id }));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Factory to create a RemoteCompute instance with given options.
|
|
139
|
+
*
|
|
140
|
+
* @param options - Remote connection configuration.
|
|
141
|
+
* @returns Instance of RemoteCompute.
|
|
142
|
+
*/
|
|
143
|
+
export function createRemoteCompute(options: RemoteComputeOptionsInterface) {
|
|
144
|
+
return new RemoteCompute(options);
|
|
145
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents the communication method used by the `RemoteCompute` backend
|
|
3
|
+
* to interact with a remote server.
|
|
4
|
+
*
|
|
5
|
+
* - `'fetch'`: Sends HTTP POST requests for each task.
|
|
6
|
+
* - `'websocket'`: Maintains a persistent WebSocket connection for bi-directional messaging.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const transport: RemoteTransportType = 'fetch';
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export type RemoteTransportType = 'fetch' | 'websocket';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration options for initializing a `RemoteCompute` backend.
|
|
17
|
+
*
|
|
18
|
+
* @property transport - The transport mechanism to use (`fetch` or `websocket`).
|
|
19
|
+
* @property endpoint - The server URL for handling task requests.
|
|
20
|
+
* @property canRunTasks - An optional list of task names this backend can handle. If omitted, it is assumed the backend can attempt all tasks.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const options: RemoteComputeOptionsInterface = {
|
|
25
|
+
* transport: 'websocket',
|
|
26
|
+
* endpoint: 'wss://api.example.com/ws',
|
|
27
|
+
* canRunTasks: ['resizeImage', 'generatePDF']
|
|
28
|
+
* };
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/fetch
|
|
32
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
|
|
33
|
+
*/
|
|
34
|
+
export interface RemoteComputeOptionsInterface {
|
|
35
|
+
transport: RemoteTransportType;
|
|
36
|
+
endpoint: string;
|
|
37
|
+
canRunTasks?: string[];
|
|
38
|
+
}
|
package/tsconfig.json
ADDED