@scriptdb/browser-client 1.0.4
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 +198 -0
- package/dist/index.js +596 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# @scriptdb/browser-client
|
|
2
|
+
|
|
3
|
+
Browser-compatible WebSocket client for ScriptDB. Provides the same API as `@scriptdb/client` but uses WebSocket instead of TCP, making it suitable for browser environments.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @scriptdb/browser-client
|
|
9
|
+
# or
|
|
10
|
+
bun add @scriptdb/browser-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { BrowserClient } from '@scriptdb/browser-client';
|
|
17
|
+
|
|
18
|
+
// Create client with URI (same format as @scriptdb/client)
|
|
19
|
+
const client = new BrowserClient('scriptdb://localhost:1234', {
|
|
20
|
+
username: 'user',
|
|
21
|
+
password: 'pass',
|
|
22
|
+
secure: false, // Use ws:// instead of wss://
|
|
23
|
+
requestTimeout: 30000,
|
|
24
|
+
retries: 3,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Connect and authenticate
|
|
28
|
+
await client.connect();
|
|
29
|
+
|
|
30
|
+
// List databases
|
|
31
|
+
const { databases } = await client.listDatabases();
|
|
32
|
+
console.log('Databases:', databases);
|
|
33
|
+
|
|
34
|
+
// Create a database
|
|
35
|
+
await client.createDatabase('mydb');
|
|
36
|
+
|
|
37
|
+
// Execute code
|
|
38
|
+
const result = await client.run(`
|
|
39
|
+
export const greeting = "Hello, World!";
|
|
40
|
+
`, 'mydb');
|
|
41
|
+
console.log(result);
|
|
42
|
+
|
|
43
|
+
// Close connection
|
|
44
|
+
client.close();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Constructor
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
new BrowserClient(uri: string, options?: ClientOptions)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### URI Format
|
|
54
|
+
|
|
55
|
+
Supports multiple URI formats:
|
|
56
|
+
- `scriptdb://localhost:1234`
|
|
57
|
+
- `ws://localhost:1235`
|
|
58
|
+
- `http://localhost:1234` (converted to scriptdb://)
|
|
59
|
+
|
|
60
|
+
Credentials can be embedded in the URI:
|
|
61
|
+
```typescript
|
|
62
|
+
new BrowserClient('scriptdb://user:pass@localhost:1234')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Options
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | Description |
|
|
68
|
+
|--------|------|---------|-------------|
|
|
69
|
+
| `secure` | `boolean` | `true` | Use secure WebSocket (wss://) |
|
|
70
|
+
| `username` | `string` | - | Username for authentication |
|
|
71
|
+
| `password` | `string` | - | Password for authentication |
|
|
72
|
+
| `requestTimeout` | `number` | `120000` | Request timeout in ms (0 = disabled) |
|
|
73
|
+
| `retries` | `number` | `3` | Number of reconnection attempts |
|
|
74
|
+
| `retryDelay` | `number` | `1000` | Initial retry delay in ms |
|
|
75
|
+
| `maxPending` | `number` | `100` | Max concurrent pending requests |
|
|
76
|
+
| `maxQueue` | `number` | `1000` | Max queued requests |
|
|
77
|
+
| `logger` | `Logger` | - | Custom logger with debug/info/warn/error |
|
|
78
|
+
| `tokenRefresh` | `function` | - | Async function to refresh expired tokens |
|
|
79
|
+
|
|
80
|
+
## API Methods
|
|
81
|
+
|
|
82
|
+
### Connection
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Connect to server
|
|
86
|
+
await client.connect();
|
|
87
|
+
|
|
88
|
+
// Check connection status
|
|
89
|
+
client.connected; // boolean
|
|
90
|
+
|
|
91
|
+
// Close connection
|
|
92
|
+
client.close();
|
|
93
|
+
|
|
94
|
+
// Disconnect (alias for close)
|
|
95
|
+
await client.disconnect();
|
|
96
|
+
|
|
97
|
+
// Destroy client completely (stops reconnection)
|
|
98
|
+
client.destroy();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Database Operations
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// List all databases
|
|
105
|
+
const { databases, count } = await client.listDatabases();
|
|
106
|
+
|
|
107
|
+
// Create a database
|
|
108
|
+
await client.createDatabase('mydb');
|
|
109
|
+
|
|
110
|
+
// Remove a database
|
|
111
|
+
await client.removeDatabase('mydb');
|
|
112
|
+
|
|
113
|
+
// Rename a database
|
|
114
|
+
await client.renameDatabase('oldname', 'newname');
|
|
115
|
+
|
|
116
|
+
// Save database to disk
|
|
117
|
+
await client.saveDatabase('mydb');
|
|
118
|
+
|
|
119
|
+
// Update database metadata
|
|
120
|
+
await client.updateDatabase('mydb', { /* metadata */ });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Code Execution
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// Execute code in a database
|
|
127
|
+
const result = await client.run(`
|
|
128
|
+
export const data = { message: "Hello" };
|
|
129
|
+
console.log("Executed!");
|
|
130
|
+
`, 'mydb');
|
|
131
|
+
|
|
132
|
+
// Result contains:
|
|
133
|
+
// - namespace: exported values
|
|
134
|
+
// - logs: console output
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Authentication
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// Login with credentials
|
|
141
|
+
await client.login('username', 'password');
|
|
142
|
+
|
|
143
|
+
// Logout
|
|
144
|
+
await client.logout();
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Server Info
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const info = await client.getInfo();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Low-level Execute
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Execute arbitrary command
|
|
157
|
+
const result = await client.execute({
|
|
158
|
+
action: 'script-code',
|
|
159
|
+
data: { code: '...', databaseName: 'mydb' }
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Features
|
|
164
|
+
|
|
165
|
+
- **URI Parsing**: Supports scriptdb://, ws://, http:// URIs
|
|
166
|
+
- **Auto-reconnect**: Automatic reconnection with exponential backoff
|
|
167
|
+
- **Token Management**: Automatic token handling and refresh support
|
|
168
|
+
- **Request Queue**: Handles concurrent requests with queue management
|
|
169
|
+
- **Logging**: Configurable logging with automatic credential masking
|
|
170
|
+
- **TypeScript**: Full TypeScript support
|
|
171
|
+
|
|
172
|
+
## WebSocket Proxy
|
|
173
|
+
|
|
174
|
+
This client connects to a WebSocket proxy server (typically running on TCP port + 1). The proxy handles communication with the ScriptDB TCP server.
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
Browser -> WebSocket (port 1235) -> Proxy -> TCP (port 1234) -> ScriptDB Server
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Compatibility with @scriptdb/client
|
|
181
|
+
|
|
182
|
+
The API is designed to be compatible with `@scriptdb/client`:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// Node.js (TCP)
|
|
186
|
+
import { ScriptDBClient } from '@scriptdb/client';
|
|
187
|
+
|
|
188
|
+
// Browser (WebSocket)
|
|
189
|
+
import { ScriptDBClient } from '@scriptdb/browser-client';
|
|
190
|
+
|
|
191
|
+
// Same API
|
|
192
|
+
const client = new ScriptDBClient('scriptdb://localhost:1234', options);
|
|
193
|
+
await client.connect();
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var noopLogger = {
|
|
3
|
+
debug: () => {},
|
|
4
|
+
info: () => {},
|
|
5
|
+
warn: () => {},
|
|
6
|
+
error: () => {}
|
|
7
|
+
};
|
|
8
|
+
async function createHmacSignature(message, secret, algorithm = "SHA-256") {
|
|
9
|
+
const encoder = new TextEncoder;
|
|
10
|
+
const keyData = encoder.encode(secret);
|
|
11
|
+
const messageData = encoder.encode(message);
|
|
12
|
+
const algoMap = {
|
|
13
|
+
sha256: "SHA-256",
|
|
14
|
+
sha384: "SHA-384",
|
|
15
|
+
sha512: "SHA-512",
|
|
16
|
+
"SHA-256": "SHA-256",
|
|
17
|
+
"SHA-384": "SHA-384",
|
|
18
|
+
"SHA-512": "SHA-512"
|
|
19
|
+
};
|
|
20
|
+
const cryptoAlgo = algoMap[algorithm] || "SHA-256";
|
|
21
|
+
const key = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: cryptoAlgo }, false, ["sign"]);
|
|
22
|
+
const signature = await crypto.subtle.sign("HMAC", key, messageData);
|
|
23
|
+
return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class BrowserClient {
|
|
27
|
+
options;
|
|
28
|
+
logger;
|
|
29
|
+
secure;
|
|
30
|
+
requestTimeout;
|
|
31
|
+
retries;
|
|
32
|
+
retryDelay;
|
|
33
|
+
maxMessageSize;
|
|
34
|
+
uri = "";
|
|
35
|
+
protocolName = "";
|
|
36
|
+
username = null;
|
|
37
|
+
password = null;
|
|
38
|
+
host = "";
|
|
39
|
+
port = 0;
|
|
40
|
+
database = null;
|
|
41
|
+
ws = null;
|
|
42
|
+
_nextId = 1;
|
|
43
|
+
_pending = new Map;
|
|
44
|
+
_maxPending = 100;
|
|
45
|
+
_maxQueue = 1000;
|
|
46
|
+
_pendingQueue = [];
|
|
47
|
+
_connected = false;
|
|
48
|
+
_authenticating = false;
|
|
49
|
+
token = null;
|
|
50
|
+
_currentRetries = 0;
|
|
51
|
+
tokenExpiry = null;
|
|
52
|
+
_destroyed = false;
|
|
53
|
+
_reconnectTimer = null;
|
|
54
|
+
_authPendingId = null;
|
|
55
|
+
signing = null;
|
|
56
|
+
_stringify = JSON.stringify;
|
|
57
|
+
ready = Promise.resolve();
|
|
58
|
+
_resolveReadyFn = null;
|
|
59
|
+
_rejectReadyFn = null;
|
|
60
|
+
_connecting = null;
|
|
61
|
+
constructor(uri, options = {}) {
|
|
62
|
+
if (!uri || typeof uri !== "string")
|
|
63
|
+
throw new Error("uri required");
|
|
64
|
+
this.options = { ...options };
|
|
65
|
+
this.maxMessageSize = Number.isFinite(this.options.maxMessageSize) ? this.options.maxMessageSize : 5 * 1024 * 1024;
|
|
66
|
+
const rawLogger = this.options.logger || noopLogger;
|
|
67
|
+
this.logger = {
|
|
68
|
+
debug: (...args) => rawLogger.debug?.(...this._maskArgs(args)),
|
|
69
|
+
info: (...args) => rawLogger.info?.(...this._maskArgs(args)),
|
|
70
|
+
warn: (...args) => rawLogger.warn?.(...this._maskArgs(args)),
|
|
71
|
+
error: (...args) => rawLogger.error?.(...this._maskArgs(args))
|
|
72
|
+
};
|
|
73
|
+
this.secure = typeof this.options.secure === "boolean" ? this.options.secure : true;
|
|
74
|
+
if (!this.secure) {
|
|
75
|
+
this.logger.warn?.("Warning: connecting in insecure mode (secure=false). This is not recommended.");
|
|
76
|
+
}
|
|
77
|
+
this.requestTimeout = Number.isFinite(this.options.requestTimeout) ? this.options.requestTimeout : 0;
|
|
78
|
+
this.retries = Number.isFinite(this.options.retries) ? this.options.retries : 3;
|
|
79
|
+
this.retryDelay = Number.isFinite(this.options.retryDelay) ? this.options.retryDelay : 1000;
|
|
80
|
+
const normalized = uri.replace(/^https?:\/\//i, "scriptdb://").replace(/^wss?:\/\//i, "scriptdb://");
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(normalized);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw new Error("Invalid uri");
|
|
86
|
+
}
|
|
87
|
+
this.uri = normalized;
|
|
88
|
+
this.protocolName = parsed.protocol ? parsed.protocol.replace(":", "") : "scriptdb";
|
|
89
|
+
this.username = (typeof this.options.username === "string" ? this.options.username : decodeURIComponent(parsed.username)) || null;
|
|
90
|
+
this.password = (typeof this.options.password === "string" ? this.options.password : decodeURIComponent(parsed.password)) || null;
|
|
91
|
+
if (parsed.username && typeof this.options.username !== "string") {
|
|
92
|
+
this.logger.warn?.("Credentials found in URI — consider passing credentials via options instead of embedding in URI");
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
parsed.username = "";
|
|
96
|
+
parsed.password = "";
|
|
97
|
+
this.uri = parsed.toString();
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.uri = normalized;
|
|
100
|
+
}
|
|
101
|
+
this.host = parsed.hostname || "localhost";
|
|
102
|
+
this.port = parsed.port ? parseInt(parsed.port, 10) : 1234;
|
|
103
|
+
this.database = parsed.pathname && parsed.pathname.length > 1 ? parsed.pathname.slice(1) : null;
|
|
104
|
+
this._maxPending = Number.isFinite(this.options.maxPending) ? this.options.maxPending : 100;
|
|
105
|
+
this._maxQueue = Number.isFinite(this.options.maxQueue) ? this.options.maxQueue : 1000;
|
|
106
|
+
this.signing = this.options.signing && this.options.signing.secret ? this.options.signing : null;
|
|
107
|
+
this._stringify = typeof this.options.stringify === "function" ? this.options.stringify : JSON.stringify;
|
|
108
|
+
this._createReady();
|
|
109
|
+
}
|
|
110
|
+
_mask(obj) {
|
|
111
|
+
try {
|
|
112
|
+
if (!obj || typeof obj !== "object")
|
|
113
|
+
return obj;
|
|
114
|
+
const copy = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
115
|
+
if (copy.token)
|
|
116
|
+
copy.token = "****";
|
|
117
|
+
if (copy.password)
|
|
118
|
+
copy.password = "****";
|
|
119
|
+
if (copy.data?.password)
|
|
120
|
+
copy.data.password = "****";
|
|
121
|
+
return copy;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return obj;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
_maskArgs(args) {
|
|
127
|
+
return args.map((a) => {
|
|
128
|
+
if (!a || typeof a === "string")
|
|
129
|
+
return a;
|
|
130
|
+
return this._mask(a);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
_createReady() {
|
|
134
|
+
this.ready = new Promise((resolve, reject) => {
|
|
135
|
+
this._resolveReadyFn = resolve;
|
|
136
|
+
this._rejectReadyFn = reject;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
_resolveReady(value) {
|
|
140
|
+
if (this._resolveReadyFn) {
|
|
141
|
+
try {
|
|
142
|
+
this._resolveReadyFn(value);
|
|
143
|
+
} catch (e) {}
|
|
144
|
+
this._resolveReadyFn = null;
|
|
145
|
+
this._rejectReadyFn = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
_rejectReady(err) {
|
|
149
|
+
if (this._rejectReadyFn) {
|
|
150
|
+
const rejectFn = this._rejectReadyFn;
|
|
151
|
+
this._resolveReadyFn = null;
|
|
152
|
+
this._rejectReadyFn = null;
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
try {
|
|
155
|
+
rejectFn(err);
|
|
156
|
+
} catch (e) {}
|
|
157
|
+
}, 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
get connected() {
|
|
161
|
+
return this._connected;
|
|
162
|
+
}
|
|
163
|
+
connect() {
|
|
164
|
+
if (this._connecting)
|
|
165
|
+
return this._connecting;
|
|
166
|
+
this._connecting = new Promise((resolve, reject) => {
|
|
167
|
+
const wsPort = this.port + 1;
|
|
168
|
+
const wsProtocol = this.secure ? "wss" : "ws";
|
|
169
|
+
const wsUrl = `${wsProtocol}://${this.host}:${wsPort}`;
|
|
170
|
+
this.logger.info?.("Connecting to", wsUrl);
|
|
171
|
+
try {
|
|
172
|
+
this.ws = new WebSocket(wsUrl);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
const error = e;
|
|
175
|
+
this.logger.error?.("Connection failed", error.message);
|
|
176
|
+
this._rejectReady(error);
|
|
177
|
+
this._createReady();
|
|
178
|
+
this._connecting = null;
|
|
179
|
+
return reject(error);
|
|
180
|
+
}
|
|
181
|
+
this.ws.onopen = () => {
|
|
182
|
+
this.logger.info?.("Connected to server");
|
|
183
|
+
this._connected = true;
|
|
184
|
+
this._currentRetries = 0;
|
|
185
|
+
this._setupListeners();
|
|
186
|
+
this.authenticate().then(() => {
|
|
187
|
+
this._processQueue();
|
|
188
|
+
resolve(this);
|
|
189
|
+
}).catch((err) => {
|
|
190
|
+
reject(err);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
this.ws.onerror = (event) => {
|
|
194
|
+
this.logger.error?.("WebSocket error:", event);
|
|
195
|
+
this._handleDisconnect(new Error("WebSocket error"));
|
|
196
|
+
};
|
|
197
|
+
this.ws.onclose = (event) => {
|
|
198
|
+
this.logger.info?.("WebSocket closed", event.code, event.reason);
|
|
199
|
+
this._handleDisconnect(null);
|
|
200
|
+
};
|
|
201
|
+
}).catch((err) => {
|
|
202
|
+
this._connecting = null;
|
|
203
|
+
throw err;
|
|
204
|
+
});
|
|
205
|
+
return this._connecting;
|
|
206
|
+
}
|
|
207
|
+
_setupListeners() {
|
|
208
|
+
if (!this.ws)
|
|
209
|
+
return;
|
|
210
|
+
this.ws.onmessage = (event) => {
|
|
211
|
+
if (typeof event.data === "string" && event.data.length > this.maxMessageSize) {
|
|
212
|
+
this.logger.error?.("Incoming message exceeds maxMessageSize — ignoring");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const msg = JSON.parse(event.data);
|
|
217
|
+
if (!msg || typeof msg !== "object")
|
|
218
|
+
return;
|
|
219
|
+
const validSchema = (typeof msg.id === "undefined" || typeof msg.id === "number") && (typeof msg.action === "string" || typeof msg.action === "undefined") && (typeof msg.command === "string" || typeof msg.command === "undefined") && (typeof msg.message === "string" || typeof msg.message === "undefined") && (typeof msg.data === "object" || typeof msg.data === "undefined" || msg.data === null);
|
|
220
|
+
if (!validSchema) {
|
|
221
|
+
this.logger.warn?.("Message failed schema validation — ignoring");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this._handleMessage(msg);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
this.logger.error?.("Failed to parse message:", error);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
_handleMessage(msg) {
|
|
231
|
+
if (!msg || typeof msg !== "object")
|
|
232
|
+
return;
|
|
233
|
+
this.logger.debug?.("Received message:", msg);
|
|
234
|
+
if (typeof msg.id !== "undefined") {
|
|
235
|
+
const pending = this._pending.get(msg.id);
|
|
236
|
+
if (!pending) {
|
|
237
|
+
this.logger.debug?.("No pending request for id", msg.id);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const { resolve, reject, timer } = pending;
|
|
241
|
+
if (timer)
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
this._pending.delete(msg.id);
|
|
244
|
+
this._processQueue();
|
|
245
|
+
if (msg.action === "login" || msg.command === "login") {
|
|
246
|
+
if (msg.message === "AUTH OK") {
|
|
247
|
+
this.token = msg.data?.token || null;
|
|
248
|
+
this._resolveReady(null);
|
|
249
|
+
return resolve(msg.data);
|
|
250
|
+
} else {
|
|
251
|
+
this._rejectReady(new Error("Authentication failed"));
|
|
252
|
+
const errorMsg = msg.data || "Authentication failed";
|
|
253
|
+
try {
|
|
254
|
+
this.ws?.close();
|
|
255
|
+
} catch (e) {}
|
|
256
|
+
return reject(new Error(typeof errorMsg === "string" ? errorMsg : "Authentication failed"));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if ((msg.action === "script-code" || msg.command === "script-code") && msg.message === "OK") {
|
|
260
|
+
return resolve(msg.data);
|
|
261
|
+
}
|
|
262
|
+
if ((msg.action === "script-code" || msg.command === "script-code") && msg.message === "ERROR") {
|
|
263
|
+
return reject(new Error(typeof msg.data === "string" ? msg.data : "Server returned ERROR"));
|
|
264
|
+
}
|
|
265
|
+
if (msg.action === "create-db" && msg.message === "SUCCESS") {
|
|
266
|
+
return resolve(msg.data);
|
|
267
|
+
}
|
|
268
|
+
if (msg.action === "create-db" && msg.message === "ERROR") {
|
|
269
|
+
return reject(new Error(typeof msg.data === "string" ? msg.data : "Failed to create database"));
|
|
270
|
+
}
|
|
271
|
+
if (msg.message === "OK" || msg.message === "SUCCESS") {
|
|
272
|
+
return resolve(msg.data);
|
|
273
|
+
} else if (msg.message === "ERROR" || msg.message === "AUTH FAIL" || msg.message === "AUTH FAILED") {
|
|
274
|
+
return reject(new Error(typeof msg.data === "string" ? msg.data : "Request failed"));
|
|
275
|
+
}
|
|
276
|
+
return reject(new Error("Invalid response from server"));
|
|
277
|
+
}
|
|
278
|
+
this.logger.debug?.("Unhandled message from server", this._mask(msg));
|
|
279
|
+
}
|
|
280
|
+
async _buildFinalMessage(payloadBase, id) {
|
|
281
|
+
const payloadObj = {
|
|
282
|
+
id,
|
|
283
|
+
action: payloadBase.action,
|
|
284
|
+
...payloadBase.data !== undefined ? { data: payloadBase.data } : {}
|
|
285
|
+
};
|
|
286
|
+
if (this.token)
|
|
287
|
+
payloadObj.token = this.token;
|
|
288
|
+
const payloadStr = this._stringify(payloadObj);
|
|
289
|
+
if (this.signing && this.signing.secret) {
|
|
290
|
+
const sig = await createHmacSignature(payloadStr, this.signing.secret, this.signing.algorithm || "sha256");
|
|
291
|
+
const envelope = { id, signature: sig, payload: payloadObj };
|
|
292
|
+
return this._stringify(envelope);
|
|
293
|
+
}
|
|
294
|
+
return payloadStr;
|
|
295
|
+
}
|
|
296
|
+
authenticate() {
|
|
297
|
+
if (this._authenticating) {
|
|
298
|
+
return Promise.reject(new Error("Already authenticating"));
|
|
299
|
+
}
|
|
300
|
+
this._authenticating = true;
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const id = this._nextId++;
|
|
303
|
+
this._authPendingId = id;
|
|
304
|
+
const payload = {
|
|
305
|
+
id,
|
|
306
|
+
action: "login",
|
|
307
|
+
data: { username: this.username, password: this.password }
|
|
308
|
+
};
|
|
309
|
+
let timer = null;
|
|
310
|
+
if (this.requestTimeout > 0) {
|
|
311
|
+
timer = window.setTimeout(() => {
|
|
312
|
+
this._pending.delete(id);
|
|
313
|
+
this._authenticating = false;
|
|
314
|
+
this._authPendingId = null;
|
|
315
|
+
reject(new Error("Auth timeout"));
|
|
316
|
+
this.close();
|
|
317
|
+
}, this.requestTimeout);
|
|
318
|
+
}
|
|
319
|
+
this._pending.set(id, {
|
|
320
|
+
resolve: (data) => {
|
|
321
|
+
if (timer)
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
this._authenticating = false;
|
|
324
|
+
this._authPendingId = null;
|
|
325
|
+
if (data?.token)
|
|
326
|
+
this.token = data.token;
|
|
327
|
+
resolve(data);
|
|
328
|
+
},
|
|
329
|
+
reject: (err) => {
|
|
330
|
+
if (timer)
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
this._authenticating = false;
|
|
333
|
+
this._authPendingId = null;
|
|
334
|
+
reject(err);
|
|
335
|
+
},
|
|
336
|
+
timer
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
this.ws?.send(JSON.stringify(payload));
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (timer)
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
this._pending.delete(id);
|
|
344
|
+
this._authenticating = false;
|
|
345
|
+
this._authPendingId = null;
|
|
346
|
+
reject(e);
|
|
347
|
+
}
|
|
348
|
+
}).then((data) => {
|
|
349
|
+
if (data?.token) {
|
|
350
|
+
this.token = data.token;
|
|
351
|
+
this._resolveReady(null);
|
|
352
|
+
}
|
|
353
|
+
return data;
|
|
354
|
+
}).catch((err) => {
|
|
355
|
+
this._rejectReady(err);
|
|
356
|
+
throw err;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async _processQueue() {
|
|
360
|
+
while (this._pending.size < this._maxPending && this._pendingQueue.length > 0) {
|
|
361
|
+
const item = this._pendingQueue.shift();
|
|
362
|
+
const { payloadBase, id, resolve, reject, timer } = item;
|
|
363
|
+
if (this.tokenExpiry && Date.now() >= this.tokenExpiry) {
|
|
364
|
+
const refreshed = await this._maybeRefreshToken();
|
|
365
|
+
if (!refreshed) {
|
|
366
|
+
if (timer)
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
try {
|
|
369
|
+
reject(new Error("Token expired and refresh failed"));
|
|
370
|
+
} catch (e) {}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
let messageStr;
|
|
375
|
+
try {
|
|
376
|
+
messageStr = await this._buildFinalMessage(payloadBase, id);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (timer)
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
try {
|
|
381
|
+
reject(e);
|
|
382
|
+
} catch (er) {}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
386
|
+
try {
|
|
387
|
+
this.ws?.send(messageStr);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
if (timer)
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
this._pending.delete(id);
|
|
392
|
+
try {
|
|
393
|
+
reject(e);
|
|
394
|
+
} catch (er) {}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async execute(payload) {
|
|
399
|
+
try {
|
|
400
|
+
await this.ready;
|
|
401
|
+
} catch (err) {
|
|
402
|
+
throw new Error("Not authenticated: " + (err?.message || err));
|
|
403
|
+
}
|
|
404
|
+
if (!this.token)
|
|
405
|
+
throw new Error("Not authenticated");
|
|
406
|
+
const id = this._nextId++;
|
|
407
|
+
const payloadBase = { action: payload.action, data: payload.data };
|
|
408
|
+
if (this.tokenExpiry && Date.now() >= this.tokenExpiry) {
|
|
409
|
+
const refreshed = await this._maybeRefreshToken();
|
|
410
|
+
if (!refreshed)
|
|
411
|
+
throw new Error("Token expired");
|
|
412
|
+
}
|
|
413
|
+
return new Promise(async (resolve, reject) => {
|
|
414
|
+
let timer = null;
|
|
415
|
+
if (this.requestTimeout > 0) {
|
|
416
|
+
timer = window.setTimeout(() => {
|
|
417
|
+
if (this._pending.has(id)) {
|
|
418
|
+
this._pending.delete(id);
|
|
419
|
+
reject(new Error("Request timeout"));
|
|
420
|
+
}
|
|
421
|
+
}, this.requestTimeout);
|
|
422
|
+
}
|
|
423
|
+
if (this._pending.size >= this._maxPending) {
|
|
424
|
+
if (this._pendingQueue.length >= this._maxQueue) {
|
|
425
|
+
if (timer)
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
return reject(new Error("Pending queue full"));
|
|
428
|
+
}
|
|
429
|
+
this._pendingQueue.push({ payloadBase, id, resolve, reject, timer });
|
|
430
|
+
this._processQueue().catch(() => {});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
let messageStr;
|
|
434
|
+
try {
|
|
435
|
+
messageStr = await this._buildFinalMessage(payloadBase, id);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
if (timer)
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
return reject(e);
|
|
440
|
+
}
|
|
441
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
442
|
+
try {
|
|
443
|
+
this.ws?.send(messageStr);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (timer)
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
this._pending.delete(id);
|
|
448
|
+
reject(e);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async sendRequest(action, data = {}) {
|
|
453
|
+
return this.execute({ action, data });
|
|
454
|
+
}
|
|
455
|
+
async _maybeRefreshToken() {
|
|
456
|
+
if (!this.options.tokenRefresh || typeof this.options.tokenRefresh !== "function") {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const res = await this.options.tokenRefresh();
|
|
461
|
+
if (res?.token) {
|
|
462
|
+
this.token = res.token;
|
|
463
|
+
if (res.expiresAt)
|
|
464
|
+
this.tokenExpiry = res.expiresAt;
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
} catch (e) {
|
|
468
|
+
this.logger.error?.("Token refresh failed", e?.message || String(e));
|
|
469
|
+
}
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
_handleDisconnect(err) {
|
|
473
|
+
if (!this._connected && !this._authenticating)
|
|
474
|
+
return;
|
|
475
|
+
const wasAuthenticating = this._authenticating;
|
|
476
|
+
this._pending.forEach((pending, id) => {
|
|
477
|
+
if (pending.timer)
|
|
478
|
+
clearTimeout(pending.timer);
|
|
479
|
+
const errorMsg = wasAuthenticating ? "Authentication failed - credentials may be required" : err?.message || "Disconnected";
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
try {
|
|
482
|
+
pending.reject(new Error(errorMsg));
|
|
483
|
+
} catch (e) {}
|
|
484
|
+
}, 0);
|
|
485
|
+
this._pending.delete(id);
|
|
486
|
+
});
|
|
487
|
+
this._connected = false;
|
|
488
|
+
this._connecting = null;
|
|
489
|
+
this._authenticating = false;
|
|
490
|
+
if (wasAuthenticating && this.retries === 0) {
|
|
491
|
+
const rejectErr = err || new Error("Authentication failed and no retries configured");
|
|
492
|
+
try {
|
|
493
|
+
this._rejectReady(rejectErr);
|
|
494
|
+
} catch (e) {}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (this._currentRetries < this.retries && !this._destroyed) {
|
|
498
|
+
this._createReady();
|
|
499
|
+
const base = Math.min(this.retryDelay * Math.pow(2, this._currentRetries), 30000);
|
|
500
|
+
const jitter = Math.floor(Math.random() * Math.min(1000, base));
|
|
501
|
+
const delay = base + jitter;
|
|
502
|
+
this._currentRetries += 1;
|
|
503
|
+
this.logger.info?.(`Attempting reconnect in ${delay}ms (attempt ${this._currentRetries})`);
|
|
504
|
+
this.token = null;
|
|
505
|
+
this._reconnectTimer = window.setTimeout(() => {
|
|
506
|
+
this.connect().catch((e) => {
|
|
507
|
+
this.logger.error?.("Reconnect failed", e?.message || e);
|
|
508
|
+
});
|
|
509
|
+
}, delay);
|
|
510
|
+
} else {
|
|
511
|
+
try {
|
|
512
|
+
this._rejectReady(err || new Error("Disconnected and no retries left"));
|
|
513
|
+
} catch (e) {}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
close() {
|
|
517
|
+
if (this.ws) {
|
|
518
|
+
try {
|
|
519
|
+
this.ws.close();
|
|
520
|
+
} catch (e) {}
|
|
521
|
+
this.ws = null;
|
|
522
|
+
}
|
|
523
|
+
this._connected = false;
|
|
524
|
+
this._cleanup();
|
|
525
|
+
}
|
|
526
|
+
destroy() {
|
|
527
|
+
this._destroyed = true;
|
|
528
|
+
if (this._reconnectTimer) {
|
|
529
|
+
clearTimeout(this._reconnectTimer);
|
|
530
|
+
this._reconnectTimer = null;
|
|
531
|
+
}
|
|
532
|
+
this._rejectReady(new Error("Client destroyed"));
|
|
533
|
+
this._pending.forEach((pending, id) => {
|
|
534
|
+
if (pending.timer)
|
|
535
|
+
clearTimeout(pending.timer);
|
|
536
|
+
try {
|
|
537
|
+
pending.reject(new Error("Client destroyed"));
|
|
538
|
+
} catch (e) {}
|
|
539
|
+
this._pending.delete(id);
|
|
540
|
+
});
|
|
541
|
+
this._pendingQueue = [];
|
|
542
|
+
this.close();
|
|
543
|
+
}
|
|
544
|
+
_cleanup() {
|
|
545
|
+
this._pending.forEach((pending) => {
|
|
546
|
+
if (pending.timer)
|
|
547
|
+
clearTimeout(pending.timer);
|
|
548
|
+
try {
|
|
549
|
+
pending.reject(new Error("Disconnected"));
|
|
550
|
+
} catch (e) {}
|
|
551
|
+
});
|
|
552
|
+
this._pending.clear();
|
|
553
|
+
}
|
|
554
|
+
async disconnect() {
|
|
555
|
+
this.close();
|
|
556
|
+
}
|
|
557
|
+
async login(username, password) {
|
|
558
|
+
return this.sendRequest("login", { username, password });
|
|
559
|
+
}
|
|
560
|
+
async logout() {
|
|
561
|
+
return this.sendRequest("logout", {});
|
|
562
|
+
}
|
|
563
|
+
async listDatabases() {
|
|
564
|
+
return this.sendRequest("list-dbs", {});
|
|
565
|
+
}
|
|
566
|
+
async createDatabase(name) {
|
|
567
|
+
return this.sendRequest("create-db", { databaseName: name });
|
|
568
|
+
}
|
|
569
|
+
async removeDatabase(name) {
|
|
570
|
+
return this.sendRequest("remove-db", { databaseName: name });
|
|
571
|
+
}
|
|
572
|
+
async renameDatabase(oldName, newName) {
|
|
573
|
+
return this.sendRequest("rename-db", { databaseName: oldName, newName });
|
|
574
|
+
}
|
|
575
|
+
async run(code, databaseName) {
|
|
576
|
+
return this.sendRequest("script-code", { code, databaseName });
|
|
577
|
+
}
|
|
578
|
+
async saveDatabase(databaseName) {
|
|
579
|
+
return this.sendRequest("save-db", { databaseName });
|
|
580
|
+
}
|
|
581
|
+
async updateDatabase(databaseName, data) {
|
|
582
|
+
return this.sendRequest("update-db", { databaseName, ...data });
|
|
583
|
+
}
|
|
584
|
+
async getInfo() {
|
|
585
|
+
return this.sendRequest("get-info", {});
|
|
586
|
+
}
|
|
587
|
+
async executeShell(command) {
|
|
588
|
+
return this.sendRequest("shell-command", { command });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
var src_default = BrowserClient;
|
|
592
|
+
export {
|
|
593
|
+
src_default as default,
|
|
594
|
+
BrowserClient as ScriptDBClient,
|
|
595
|
+
BrowserClient
|
|
596
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scriptdb/browser-client",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Client module resolver for script database",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "bun --watch src/index.ts",
|
|
20
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm --splitting --external bcryptjs --external bottleneck --external jsonwebtoken --external lru-cache",
|
|
21
|
+
"build:cjs": "bun build src/index.ts --outdir dist --target node --format cjs --outfile dist/index.js --external bcryptjs --external bottleneck --external jsonwebtoken --external lru-cache",
|
|
22
|
+
"build:types": "tsc --emitDeclarationOnly",
|
|
23
|
+
"build:all": "bun run build && bun run build:cjs && bun run build:types",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"lint": "bun run lint:src",
|
|
26
|
+
"lint:src": "eslint src --ext .ts,.tsx",
|
|
27
|
+
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"clean": "rm -rf dist"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bcryptjs": "^3.0.0",
|
|
33
|
+
"@types/bun": "^1.3.2",
|
|
34
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
35
|
+
"@types/lru-cache": "^7.10.10",
|
|
36
|
+
"@types/node": "^24.10.1",
|
|
37
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
38
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
39
|
+
"bun-types": "latest",
|
|
40
|
+
"eslint": "^8.0.0",
|
|
41
|
+
"typescript": "^5.0.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"bcryptjs": "^3.0.3",
|
|
45
|
+
"bottleneck": "^2.19.5",
|
|
46
|
+
"jsonwebtoken": "^9.0.2",
|
|
47
|
+
"lru-cache": "^11.2.2"
|
|
48
|
+
}
|
|
49
|
+
}
|