Skip to content

Commit 50a01c8

Browse files
committed
refactor: seperate phoenix-fs.js node websocket handler layer so that it can be imported directly by node clients
1 parent 4b55a18 commit 50a01c8

12 files changed

Lines changed: 1918 additions & 452 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ src-tauri/target
99
dist/*
1010
!dist/virtualfs.js
1111
!dist/virtualfs.js.map
12+
!dist/phoenix-fs.js
1213
test/virtualfs.js
1314
test/virtualfs.js.map
1415
phcode-fs-*.tgz

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,53 @@ Then:
164164
2. Copy all `#[tauri::command]` from `src-tauri/src/main.rs` to your tauri main file.
165165
3. Update your `tauri::Builder::default()` section in your tauri `main fn()`
166166

167+
Certainly! Here's a polished presentation for a GitHub `README.md` for your project:
168+
169+
---
170+
171+
## Usage in Tauri with Node Websocket Connector
172+
173+
Tauri APIs are accessible exclusively from the main thread.
174+
As a workaround, we provide a unique connector that facilitates communication with Node.js
175+
through websockets directly from the browser, granting access to the file system from webWorkers.
176+
This setup ensures flexibility, enabling the utilization of this library from both the primary browser tab and any worker threads.
177+
178+
For a detailed Node.js implementation, refer to this repository.
179+
If the requirement arises to bundle your Node binary, the tauri sidecar feature can be used to bundle node with your tauri app.
180+
181+
### Example: Setting Up Your Own `phoenix-fs` Server in Node.js
182+
183+
Below is a quick guide to get your Phoenix-FS server up and running.
184+
185+
```javascript
186+
// If you're using CommonJS syntax:
187+
const { CreatePhoenixFsServer } = require('@phcode/fs/dist/phoenix-fs');
188+
189+
// If you prefer ES6 module syntax, use the import statement instead:
190+
// import { CreatePhoenixFsServer } from '@phcode/fs/dist/phoenix-fs';
191+
192+
// Initialize an HTTP server
193+
const server = http.createServer((req, res) => {
194+
res.writeHead(200, { 'Content-Type': 'text/plain' });
195+
res.end('WebSocket server is operational');
196+
});
197+
198+
// Attach the Phoenix websocket server to the HTTP server.
199+
// By default, the WebSocket server endpoint will be `ws://localhost:3000/phoenixFS`
200+
CreatePhoenixFsServer(server);
201+
202+
// If you wish to use a custom path, pass it as the second argument:
203+
// CreatePhoenixFsServer(server, "/yourCustomPath");
204+
205+
// Activate the HTTP server on port 3000
206+
const port = 3000;
207+
server.listen(port, () => {
208+
console.log(`Server is live on http://localhost:${port}`);
209+
});
210+
```
211+
212+
Save the code above in a file, run it, and you'll have both an HTTP server and WebSocket server running concurrently.
213+
167214
## Development
168215
This segment is dedicated to those contributing or modifying the codebase of this repository.
169216
If you are just using this as a library, please skip this section.
@@ -807,3 +854,17 @@ fs.writeFile("/path/to/file", "Hello World", 'utf8', function(err) {
807854
console.log("File written successfully!");
808855
});
809856
```
857+
858+
859+
### `fs.setNodeWSEndpoint(websocketEndpoint)`
860+
861+
Sets the websocket endpoint and returns a promise that resolves
862+
when the tauri node fs connection is open. It ensures the socket remains
863+
open across failures and automatically reconnects as necessary.
864+
865+
- **Parameters:**
866+
- `websocketEndpoint` : string. Eg.: `ws://localhost:3000/phoenixFS`
867+
868+
- **Returns:**
869+
- Promise<void>
870+

dist/phoenix-fs.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
const WebSocket = require('ws');
2+
3+
/**
4+
*
5+
* @param metadata {Object} Max size can be 4GB
6+
* @param bufferData {ArrayBuffer} [optional]
7+
* @return {ArrayBuffer}
8+
* @private
9+
*/
10+
function mergeMetadataAndArrayBuffer(metadata, bufferData) {
11+
bufferData = bufferData || new ArrayBuffer(0);
12+
if (typeof metadata !== 'object') {
13+
throw new Error("metadata should be an object, but was " + typeof metadata);
14+
}
15+
if (!(bufferData instanceof ArrayBuffer)) {
16+
throw new Error("Expected bufferData to be an instance of ArrayBuffer, but was " + typeof bufferData);
17+
}
18+
19+
const metadataString = JSON.stringify(metadata);
20+
const metadataUint8Array = new TextEncoder().encode(metadataString);
21+
const metadataBuffer = metadataUint8Array.buffer;
22+
const sizePrefixLength = 4; // 4 bytes for a 32-bit integer
23+
24+
if (metadataBuffer.byteLength > 4294000000) {
25+
throw new Error("metadata too large. Should be below 4,294MB, but was " + metadataBuffer.byteLength);
26+
}
27+
28+
const concatenatedBuffer = new ArrayBuffer(sizePrefixLength + metadataBuffer.byteLength + bufferData.byteLength);
29+
const concatenatedUint8Array = new Uint8Array(concatenatedBuffer);
30+
31+
// Write the length of metadataBuffer as a 32-bit integer
32+
new DataView(concatenatedBuffer).setUint32(0, metadataBuffer.byteLength, true);
33+
34+
// Copy the metadataUint8Array and bufferData (if provided) to the concatenatedUint8Array
35+
concatenatedUint8Array.set(metadataUint8Array, sizePrefixLength);
36+
if (bufferData.byteLength > 0) {
37+
concatenatedUint8Array.set(new Uint8Array(bufferData), sizePrefixLength + metadataBuffer.byteLength);
38+
}
39+
40+
return concatenatedBuffer;
41+
}
42+
43+
function splitMetadataAndBuffer(concatenatedBuffer) {
44+
if(!(concatenatedBuffer instanceof ArrayBuffer)){
45+
throw new Error("Expected ArrayBuffer message from websocket");
46+
}
47+
const sizePrefixLength = 4;
48+
const buffer1Length = new DataView(concatenatedBuffer).getUint32(0, true); // Little endian
49+
50+
const buffer1 = concatenatedBuffer.slice(sizePrefixLength, sizePrefixLength + buffer1Length);
51+
let buffer2;
52+
if (concatenatedBuffer.byteLength > sizePrefixLength + buffer1Length) {
53+
buffer2 = concatenatedBuffer.slice(sizePrefixLength + buffer1Length);
54+
}
55+
56+
return {
57+
metadata: JSON.parse(new TextDecoder().decode(buffer1)),
58+
bufferData: buffer2
59+
};
60+
}
61+
62+
63+
const WS_COMMAND = {
64+
PING: "ping",
65+
RESPONSE: "response",
66+
LARGE_DATA_SOCKET_ANNOUNCE: "largeDataSock"
67+
};
68+
69+
const LARGE_DATA_THRESHOLD = 2*1024*1024; // 2MB
70+
// A map from dataSocketID to the actual data socket that is used for transporting large data only.
71+
// binary data larger than 2MB is considered large data and we will try to send it through a large data web socket if present.
72+
// a client typically makes 2 websockets, one for small data and another for large data transport.
73+
// so large file transfers wont put pressure on the websocket.
74+
const largeDataSocketMap = {};
75+
76+
function _getResponse(originalMetadata, data = null) {
77+
return {
78+
commandCode: WS_COMMAND.RESPONSE,
79+
commandId: originalMetadata.commandId,
80+
socketGroupID: originalMetadata.socketGroupID,
81+
data
82+
}
83+
}
84+
85+
/**
86+
*
87+
* @param ws
88+
* @param metadata
89+
* @param dataObjectToSend
90+
* @param dataBuffer {ArrayBuffer}
91+
* @private
92+
*/
93+
function _sendResponse(ws, metadata, dataObjectToSend = null, dataBuffer = new ArrayBuffer(0)) {
94+
const response = _getResponse(metadata, dataObjectToSend);
95+
let socketToUse = ws, largeDataSocket = largeDataSocketMap[metadata.socketGroupID];
96+
if(dataBuffer && dataBuffer.byteLength > LARGE_DATA_THRESHOLD && largeDataSocket) {
97+
socketToUse = largeDataSocket;
98+
}
99+
socketToUse.send(mergeMetadataAndArrayBuffer(response, dataBuffer));
100+
}
101+
102+
function processWSCommand(ws, metadata, dataBuffer) {
103+
try{
104+
switch (metadata.commandCode) {
105+
case WS_COMMAND.PING: _sendResponse(ws, metadata, metadata.data, dataBuffer); return;
106+
case WS_COMMAND.LARGE_DATA_SOCKET_ANNOUNCE:
107+
ws.isLargeData = true;
108+
ws.LargeDataSocketGroupID = metadata.socketGroupID;
109+
largeDataSocketMap[metadata.socketGroupID] = ws;
110+
_sendResponse(ws, metadata, {}, dataBuffer); return;
111+
default: console.error("unknown command: "+ metadata);
112+
}
113+
} catch (e) {
114+
console.error(e);
115+
}
116+
}
117+
118+
function processWebSocketMessage(ws, message) {
119+
const {metadata, bufferData} = splitMetadataAndBuffer(message);
120+
processWSCommand(ws, metadata, bufferData);
121+
}
122+
123+
function CreatePhoenixFsServer(server, wssPath = "/phoenixFS") {
124+
// Create a WebSocket server by passing the HTTP server instance to WebSocket.Server
125+
const wss = new WebSocket.Server({
126+
noServer: true,
127+
perMessageDeflate: false, // dont compress to improve performance and since we are on localhost.
128+
maxPayload: 2048 * 1024 * 1024 // 2GB Max message payload size
129+
});
130+
131+
server.on('upgrade', (request, socket, head) => {
132+
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname;
133+
if (pathname === wssPath) {
134+
wss.handleUpgrade(request, socket, head, (ws) => {
135+
wss.emit('connection', ws, request);
136+
});
137+
} else {
138+
// Not handling the upgrade here. Let the next listener deal with it.
139+
}
140+
});
141+
142+
// Set up a connection listener
143+
wss.on('connection', (ws) => {
144+
console.log('Websocket Client connected');
145+
ws.binaryType = 'arraybuffer';
146+
147+
// Listen for messages from the client
148+
ws.on('message', (message) => {
149+
console.log(`Received message ${message} of size: ${message.byteLength}, type: ${typeof message}, isArrayBuffer: ${message instanceof ArrayBuffer}, isBuffer: ${Buffer.isBuffer(message)}`);
150+
processWebSocketMessage(ws, message);
151+
});
152+
153+
ws.on('error', console.error);
154+
155+
// Handle disconnection
156+
ws.on('close', () => {
157+
if(ws.isLargeData && ws.socketGroupID && largeDataSocketMap[ws.socketGroupID] === ws){
158+
delete largeDataSocketMap[ws.socketGroupID];
159+
}
160+
console.log('Websocket Client disconnected');
161+
});
162+
});
163+
}
164+
165+
exports.CreatePhoenixFsServer = CreatePhoenixFsServer;

dist/virtualfs.js

Lines changed: 1060 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/virtualfs.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)