@boristype/ws-client 0.1.0-alpha.0
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/LICENSE +21 -0
- package/build/client.d.ts +69 -0
- package/build/client.js +156 -0
- package/build/evaluator.d.ts +51 -0
- package/build/evaluator.js +107 -0
- package/build/exceptions.d.ts +12 -0
- package/build/exceptions.js +20 -0
- package/build/index.d.ts +12 -0
- package/build/index.js +10 -0
- package/build/soap-utils.d.ts +18 -0
- package/build/soap-utils.js +224 -0
- package/build/types.d.ts +17 -0
- package/build/types.js +1 -0
- package/build/uploader.d.ts +111 -0
- package/build/uploader.js +283 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BorisType
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { WshcmClientOptions } from './types.js';
|
|
2
|
+
import { Evaluator } from './evaluator.js';
|
|
3
|
+
/**
|
|
4
|
+
* Клиент для работы с WebSoft HCM через SP-XML API
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* const client = new WshcmClient({
|
|
9
|
+
* overHttps: false,
|
|
10
|
+
* host: 'localhost',
|
|
11
|
+
* port: 8080,
|
|
12
|
+
* username: 'admin',
|
|
13
|
+
* password: 'password'
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* await client.initialize();
|
|
17
|
+
* const result = await client.callMethod<string>('tools', 'some_method', ['arg1', 'arg2']);
|
|
18
|
+
*
|
|
19
|
+
* const evaluator = client.createEvaluator();
|
|
20
|
+
* await evaluator.initialize();
|
|
21
|
+
* // ... use evaluator ...
|
|
22
|
+
* await evaluator.close();
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare class WshcmClient {
|
|
26
|
+
private baseUrl;
|
|
27
|
+
private username;
|
|
28
|
+
private password;
|
|
29
|
+
private cookies;
|
|
30
|
+
private isHttps;
|
|
31
|
+
private requestTimeout;
|
|
32
|
+
/**
|
|
33
|
+
* Создает новый экземпляр WSHCM-клиента
|
|
34
|
+
* @param options - опции подключения
|
|
35
|
+
*/
|
|
36
|
+
constructor(options: WshcmClientOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Инициализирует клиент и проверяет авторизацию
|
|
39
|
+
* Должен быть вызван после создания экземпляра
|
|
40
|
+
*/
|
|
41
|
+
initialize(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Вызывает метод через SP-XML API
|
|
44
|
+
* @template T - тип возвращаемого значения
|
|
45
|
+
* @param lib - библиотека/модуль (например, "tools", или URL модуля)
|
|
46
|
+
* @param method - имя метода
|
|
47
|
+
* @param methodArgs - массив аргументов метода
|
|
48
|
+
* @returns результат выполнения метода
|
|
49
|
+
*/
|
|
50
|
+
callMethod<T = any>(lib: string, method: string, methodArgs?: any[]): Promise<T>;
|
|
51
|
+
/**
|
|
52
|
+
* Создаёт новый Evaluator для выполнения произвольного кода на сервере.
|
|
53
|
+
*
|
|
54
|
+
* Вызывающий код отвечает за lifecycle evaluator-а:
|
|
55
|
+
* вызов `initialize()` перед использованием и `close()` после завершения.
|
|
56
|
+
*
|
|
57
|
+
* @returns новый экземпляр Evaluator
|
|
58
|
+
*/
|
|
59
|
+
createEvaluator(): Evaluator;
|
|
60
|
+
/**
|
|
61
|
+
* Выполняет HTTP-запрос
|
|
62
|
+
* @param path - путь запроса
|
|
63
|
+
* @param method - HTTP-метод
|
|
64
|
+
* @param headers - заголовки
|
|
65
|
+
* @param body - тело запроса
|
|
66
|
+
* @returns статус-код или тело ответа
|
|
67
|
+
*/
|
|
68
|
+
private makeRequest;
|
|
69
|
+
}
|
package/build/client.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as https from 'https';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import { UnauthorizedError, WshcmException } from './exceptions.js';
|
|
4
|
+
import { renderRequest, parseResponse } from './soap-utils.js';
|
|
5
|
+
import { Evaluator } from './evaluator.js';
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Main WSHCM Client
|
|
8
|
+
// ============================================================================
|
|
9
|
+
/** Таймаут HTTP-запросов по умолчанию (30 секунд) */
|
|
10
|
+
const DEFAULT_REQUEST_TIMEOUT = 30_000;
|
|
11
|
+
/**
|
|
12
|
+
* Клиент для работы с WebSoft HCM через SP-XML API
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const client = new WshcmClient({
|
|
17
|
+
* overHttps: false,
|
|
18
|
+
* host: 'localhost',
|
|
19
|
+
* port: 8080,
|
|
20
|
+
* username: 'admin',
|
|
21
|
+
* password: 'password'
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* await client.initialize();
|
|
25
|
+
* const result = await client.callMethod<string>('tools', 'some_method', ['arg1', 'arg2']);
|
|
26
|
+
*
|
|
27
|
+
* const evaluator = client.createEvaluator();
|
|
28
|
+
* await evaluator.initialize();
|
|
29
|
+
* // ... use evaluator ...
|
|
30
|
+
* await evaluator.close();
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class WshcmClient {
|
|
34
|
+
baseUrl;
|
|
35
|
+
username;
|
|
36
|
+
password;
|
|
37
|
+
cookies = [];
|
|
38
|
+
isHttps;
|
|
39
|
+
requestTimeout;
|
|
40
|
+
/**
|
|
41
|
+
* Создает новый экземпляр WSHCM-клиента
|
|
42
|
+
* @param options - опции подключения
|
|
43
|
+
*/
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.isHttps = options.overHttps;
|
|
46
|
+
this.baseUrl = `${options.overHttps ? 'https' : 'http'}://${options.host}:${options.port}`;
|
|
47
|
+
this.username = options.username;
|
|
48
|
+
this.password = options.password;
|
|
49
|
+
this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Инициализирует клиент и проверяет авторизацию
|
|
53
|
+
* Должен быть вызван после создания экземпляра
|
|
54
|
+
*/
|
|
55
|
+
async initialize() {
|
|
56
|
+
const statusCode = await this.makeRequest('/spxml_web/main.htm', 'GET');
|
|
57
|
+
if (statusCode !== 200) {
|
|
58
|
+
throw new UnauthorizedError();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Вызывает метод через SP-XML API
|
|
63
|
+
* @template T - тип возвращаемого значения
|
|
64
|
+
* @param lib - библиотека/модуль (например, "tools", или URL модуля)
|
|
65
|
+
* @param method - имя метода
|
|
66
|
+
* @param methodArgs - массив аргументов метода
|
|
67
|
+
* @returns результат выполнения метода
|
|
68
|
+
*/
|
|
69
|
+
async callMethod(lib, method, methodArgs = []) {
|
|
70
|
+
const headers = {
|
|
71
|
+
'accept': '*/*',
|
|
72
|
+
'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
73
|
+
'cache-control': 'no-cache',
|
|
74
|
+
'content-type': 'application/soap+xml; charset=UTF-8',
|
|
75
|
+
'pragma': 'no-cache',
|
|
76
|
+
'x-spxml-client': 'SP-XML Web client 1.0',
|
|
77
|
+
};
|
|
78
|
+
const body = renderRequest(lib, method, methodArgs);
|
|
79
|
+
const responseText = await this.makeRequest('/api/spxml/CallMethod', 'POST', headers, body);
|
|
80
|
+
return parseResponse(responseText);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Создаёт новый Evaluator для выполнения произвольного кода на сервере.
|
|
84
|
+
*
|
|
85
|
+
* Вызывающий код отвечает за lifecycle evaluator-а:
|
|
86
|
+
* вызов `initialize()` перед использованием и `close()` после завершения.
|
|
87
|
+
*
|
|
88
|
+
* @returns новый экземпляр Evaluator
|
|
89
|
+
*/
|
|
90
|
+
createEvaluator() {
|
|
91
|
+
return new Evaluator(this);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Выполняет HTTP-запрос
|
|
95
|
+
* @param path - путь запроса
|
|
96
|
+
* @param method - HTTP-метод
|
|
97
|
+
* @param headers - заголовки
|
|
98
|
+
* @param body - тело запроса
|
|
99
|
+
* @returns статус-код или тело ответа
|
|
100
|
+
*/
|
|
101
|
+
makeRequest(path, method, headers = {}, body) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const url = new URL(this.baseUrl + path);
|
|
104
|
+
const auth = Buffer.from(`${this.username}:${this.password}`).toString('base64');
|
|
105
|
+
const allHeaders = {
|
|
106
|
+
...headers,
|
|
107
|
+
'Authorization': `Basic ${auth}`,
|
|
108
|
+
};
|
|
109
|
+
if (this.cookies.length > 0) {
|
|
110
|
+
allHeaders['Cookie'] = this.cookies.join('; ');
|
|
111
|
+
}
|
|
112
|
+
if (body) {
|
|
113
|
+
allHeaders['Content-Length'] = Buffer.byteLength(body).toString();
|
|
114
|
+
}
|
|
115
|
+
const options = {
|
|
116
|
+
hostname: url.hostname,
|
|
117
|
+
port: url.port,
|
|
118
|
+
path: url.pathname + url.search,
|
|
119
|
+
method: method,
|
|
120
|
+
headers: allHeaders,
|
|
121
|
+
timeout: this.requestTimeout,
|
|
122
|
+
};
|
|
123
|
+
const lib = this.isHttps ? https : http;
|
|
124
|
+
const req = lib.request(options, (res) => {
|
|
125
|
+
// Сохраняем cookies
|
|
126
|
+
const setCookie = res.headers['set-cookie'];
|
|
127
|
+
if (setCookie) {
|
|
128
|
+
this.cookies.push(...setCookie.map(cookie => cookie.split(';')[0]));
|
|
129
|
+
}
|
|
130
|
+
let data = '';
|
|
131
|
+
res.on('data', (chunk) => {
|
|
132
|
+
data += chunk;
|
|
133
|
+
});
|
|
134
|
+
res.on('end', () => {
|
|
135
|
+
if (method === 'GET') {
|
|
136
|
+
resolve(res.statusCode || 500);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
resolve(data);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
req.on('timeout', () => {
|
|
144
|
+
req.destroy();
|
|
145
|
+
reject(new WshcmException(`Request timed out after ${this.requestTimeout}ms: ${method} ${path}`));
|
|
146
|
+
});
|
|
147
|
+
req.on('error', (error) => {
|
|
148
|
+
reject(new WshcmException(`Request failed: ${error.message}`));
|
|
149
|
+
});
|
|
150
|
+
if (body) {
|
|
151
|
+
req.write(body);
|
|
152
|
+
}
|
|
153
|
+
req.end();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { WshcmClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Evaluator для выполнения произвольного BorisScript-кода на сервере
|
|
4
|
+
*
|
|
5
|
+
* Создает временный модуль на сервере с функциями execute и drop_self,
|
|
6
|
+
* позволяя выполнять динамический код через eval.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const evaluator = client.evaluator;
|
|
11
|
+
* await evaluator.initialize();
|
|
12
|
+
* const result = await evaluator.eval('return 2 + 2;');
|
|
13
|
+
* await evaluator.close();
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare class Evaluator {
|
|
17
|
+
private client;
|
|
18
|
+
private evalKey;
|
|
19
|
+
private libUrl;
|
|
20
|
+
private initialized;
|
|
21
|
+
/**
|
|
22
|
+
* Создает новый evaluator
|
|
23
|
+
* @param client - экземпляр WshcmClient
|
|
24
|
+
*/
|
|
25
|
+
constructor(client: WshcmClient);
|
|
26
|
+
/**
|
|
27
|
+
* Инициализирует evaluator, создавая временный модуль на сервере
|
|
28
|
+
*/
|
|
29
|
+
initialize(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Выполняет произвольный BorisScript-код на сервере
|
|
32
|
+
* @param code - код для выполнения
|
|
33
|
+
* @returns результат выполнения
|
|
34
|
+
*/
|
|
35
|
+
eval(code: string): Promise<any>;
|
|
36
|
+
evalCode(code: string): Promise<any>;
|
|
37
|
+
evalExpr(expr: string): Promise<any>;
|
|
38
|
+
/**
|
|
39
|
+
* Закрывает evaluator и удаляет временный модуль с сервера
|
|
40
|
+
* @returns true если удаление успешно, false в противном случае
|
|
41
|
+
*/
|
|
42
|
+
close(): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Генерирует случайную строку заданной длины
|
|
45
|
+
*/
|
|
46
|
+
private generateRandomString;
|
|
47
|
+
/**
|
|
48
|
+
* Возвращает содержимое временного модуля unsafe_eval.bs
|
|
49
|
+
*/
|
|
50
|
+
private getUnsafeEvalScript;
|
|
51
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluator для выполнения произвольного BorisScript-кода на сервере
|
|
3
|
+
*
|
|
4
|
+
* Создает временный модуль на сервере с функциями execute и drop_self,
|
|
5
|
+
* позволяя выполнять динамический код через eval.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const evaluator = client.evaluator;
|
|
10
|
+
* await evaluator.initialize();
|
|
11
|
+
* const result = await evaluator.eval('return 2 + 2;');
|
|
12
|
+
* await evaluator.close();
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class Evaluator {
|
|
16
|
+
client;
|
|
17
|
+
evalKey;
|
|
18
|
+
libUrl;
|
|
19
|
+
initialized = false;
|
|
20
|
+
/**
|
|
21
|
+
* Создает новый evaluator
|
|
22
|
+
* @param client - экземпляр WshcmClient
|
|
23
|
+
*/
|
|
24
|
+
constructor(client) {
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.evalKey = 'unsafe_eval_' + this.generateRandomString(32);
|
|
27
|
+
this.libUrl = `x-local://wt/${this.evalKey}.bs`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Инициализирует evaluator, создавая временный модуль на сервере
|
|
31
|
+
*/
|
|
32
|
+
async initialize() {
|
|
33
|
+
if (this.initialized) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const content = this.getUnsafeEvalScript();
|
|
37
|
+
await this.client.callMethod('tools', 'put_url_text_server', [this.libUrl, content]);
|
|
38
|
+
this.initialized = true;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Выполняет произвольный BorisScript-код на сервере
|
|
42
|
+
* @param code - код для выполнения
|
|
43
|
+
* @returns результат выполнения
|
|
44
|
+
*/
|
|
45
|
+
async eval(code) {
|
|
46
|
+
if (!this.initialized) {
|
|
47
|
+
await this.initialize();
|
|
48
|
+
}
|
|
49
|
+
return this.client.callMethod(this.libUrl, 'execute', [code]);
|
|
50
|
+
}
|
|
51
|
+
async evalCode(code) {
|
|
52
|
+
// Оборачиваем код в функцию, чтобы явно не возвращать никакого результата всего сегмента кода, даже если в коде есть return
|
|
53
|
+
const randomFuncName = '__evaluator_wrapper_' + this.generateRandomString(32);
|
|
54
|
+
const wrappedCode = `function ${randomFuncName}() {\n${code}\n}\n${randomFuncName}();return;`;
|
|
55
|
+
return this.eval(wrappedCode);
|
|
56
|
+
}
|
|
57
|
+
async evalExpr(expr) {
|
|
58
|
+
return this.eval(expr);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Закрывает evaluator и удаляет временный модуль с сервера
|
|
62
|
+
* @returns true если удаление успешно, false в противном случае
|
|
63
|
+
*/
|
|
64
|
+
async close() {
|
|
65
|
+
if (!this.initialized) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const result = await this.client.callMethod(this.libUrl, 'drop_self', []);
|
|
70
|
+
this.initialized = false;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Генерирует случайную строку заданной длины
|
|
79
|
+
*/
|
|
80
|
+
generateRandomString(length) {
|
|
81
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
82
|
+
let result = '';
|
|
83
|
+
for (let i = 0; i < length; i++) {
|
|
84
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Возвращает содержимое временного модуля unsafe_eval.bs
|
|
90
|
+
*/
|
|
91
|
+
getUnsafeEvalScript() {
|
|
92
|
+
return `"META:ALLOW-CALL-FROM-CLIENT:1"
|
|
93
|
+
function execute(code) {
|
|
94
|
+
return eval(code);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
"META:ALLOW-CALL-FROM-CLIENT:1"
|
|
98
|
+
function drop_self() {
|
|
99
|
+
try {
|
|
100
|
+
DeleteFile("${this.libUrl}");
|
|
101
|
+
return true;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Базовое исключение для WSHCM-клиента
|
|
3
|
+
*/
|
|
4
|
+
export declare class WshcmException extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Исключение при ошибке авторизации
|
|
9
|
+
*/
|
|
10
|
+
export declare class UnauthorizedError extends WshcmException {
|
|
11
|
+
constructor(message?: string);
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Базовое исключение для WSHCM-клиента
|
|
3
|
+
*/
|
|
4
|
+
export class WshcmException extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'WshcmException';
|
|
8
|
+
Object.setPrototypeOf(this, WshcmException.prototype);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Исключение при ошибке авторизации
|
|
13
|
+
*/
|
|
14
|
+
export class UnauthorizedError extends WshcmException {
|
|
15
|
+
constructor(message = 'Unauthorized') {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'UnauthorizedError';
|
|
18
|
+
Object.setPrototypeOf(this, UnauthorizedError.prototype);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSHCM Client - клиент для работы с WebSoft HCM через SP-XML API
|
|
3
|
+
*
|
|
4
|
+
* @module wshcm
|
|
5
|
+
*/
|
|
6
|
+
export { WshcmClient } from './client.js';
|
|
7
|
+
export { Evaluator } from './evaluator.js';
|
|
8
|
+
export { WshcmException, UnauthorizedError } from './exceptions.js';
|
|
9
|
+
export { renderRequest, parseResponse } from './soap-utils.js';
|
|
10
|
+
export { WshcmUploader } from './uploader.js';
|
|
11
|
+
export type { WshcmClientOptions } from './types.js';
|
|
12
|
+
export type { WshcmUploaderOptions, UploadProgressCallback } from './uploader.js';
|
package/build/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSHCM Client - клиент для работы с WebSoft HCM через SP-XML API
|
|
3
|
+
*
|
|
4
|
+
* @module wshcm
|
|
5
|
+
*/
|
|
6
|
+
export { WshcmClient } from './client.js';
|
|
7
|
+
export { Evaluator } from './evaluator.js';
|
|
8
|
+
export { WshcmException, UnauthorizedError } from './exceptions.js';
|
|
9
|
+
export { renderRequest, parseResponse } from './soap-utils.js';
|
|
10
|
+
export { WshcmUploader } from './uploader.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Рендерит SOAP-запрос для вызова метода
|
|
3
|
+
* @param lib - библиотека/модуль
|
|
4
|
+
* @param method - имя метода
|
|
5
|
+
* @param methodArgs - аргументы метода
|
|
6
|
+
* @returns XML-строка SOAP-запроса
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderRequest(lib: string, method: string, methodArgs: any[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* Парсит SOAP-ответ и извлекает результат.
|
|
11
|
+
*
|
|
12
|
+
* Использует `fast-xml-parser` с `removeNSPrefix: true` для прозрачной
|
|
13
|
+
* работы с namespace-префиксами (soap:Envelope → Envelope и т.д.).
|
|
14
|
+
*
|
|
15
|
+
* @param responseText - XML-строка ответа
|
|
16
|
+
* @returns результат выполнения метода
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseResponse(responseText: string): any;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
|
2
|
+
import { WshcmException } from './exceptions.js';
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// XML Parser / Builder instances
|
|
5
|
+
// ============================================================================
|
|
6
|
+
/**
|
|
7
|
+
* XMLParser для SOAP-ответов.
|
|
8
|
+
* `removeNSPrefix: true` убирает namespace-префиксы (soap:Envelope → Envelope),
|
|
9
|
+
* что позволяет обращаться к элементам напрямую без привязки к префиксам сервера.
|
|
10
|
+
*/
|
|
11
|
+
const soapParser = new XMLParser({
|
|
12
|
+
ignoreAttributes: false,
|
|
13
|
+
attributeNamePrefix: '@_',
|
|
14
|
+
removeNSPrefix: true,
|
|
15
|
+
trimValues: true,
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* XMLBuilder для формирования SOAP-запросов.
|
|
19
|
+
* Автоматически экранирует XML-сущности в значениях и атрибутах.
|
|
20
|
+
*/
|
|
21
|
+
const soapBuilder = new XMLBuilder({
|
|
22
|
+
ignoreAttributes: false,
|
|
23
|
+
attributeNamePrefix: '@_',
|
|
24
|
+
format: true,
|
|
25
|
+
indentBy: ' ',
|
|
26
|
+
suppressEmptyNode: false,
|
|
27
|
+
processEntities: true,
|
|
28
|
+
});
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Request Building
|
|
31
|
+
// ============================================================================
|
|
32
|
+
/**
|
|
33
|
+
* Преобразует аргумент метода в объект для XMLBuilder
|
|
34
|
+
* @param arg - значение аргумента
|
|
35
|
+
* @returns объект с VALUE-TYPE атрибутом и текстовым содержимым
|
|
36
|
+
*/
|
|
37
|
+
function buildArgumentValue(arg) {
|
|
38
|
+
if (typeof arg === 'string') {
|
|
39
|
+
return {
|
|
40
|
+
'@_VALUE-TYPE': 'string',
|
|
41
|
+
'#text': arg
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (typeof arg === 'number') {
|
|
45
|
+
return {
|
|
46
|
+
'@_VALUE-TYPE': Number.isInteger(arg) ? 'integer' : 'real',
|
|
47
|
+
'#text': arg,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (typeof arg === 'boolean') {
|
|
51
|
+
return {
|
|
52
|
+
'@_VALUE-TYPE': 'bool',
|
|
53
|
+
'#text': arg ? 1 : 0
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (arg === null || arg === undefined) {
|
|
57
|
+
return { '@_VALUE-TYPE': 'string', '#text': '' };
|
|
58
|
+
}
|
|
59
|
+
// Для объектов и массивов — неподдерживаемый тип, используем JSON как строку
|
|
60
|
+
try {
|
|
61
|
+
return { '@_VALUE-TYPE': 'string', '#text': JSON.stringify(arg) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new WshcmException('Unsupported argument type');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Рендерит SOAP-запрос для вызова метода
|
|
69
|
+
* @param lib - библиотека/модуль
|
|
70
|
+
* @param method - имя метода
|
|
71
|
+
* @param methodArgs - аргументы метода
|
|
72
|
+
* @returns XML-строка SOAP-запроса
|
|
73
|
+
*/
|
|
74
|
+
export function renderRequest(lib, method, methodArgs) {
|
|
75
|
+
const argData = {};
|
|
76
|
+
for (let i = 0; i < methodArgs.length; i++) {
|
|
77
|
+
argData[`arg-${i}`] = buildArgumentValue(methodArgs[i]);
|
|
78
|
+
}
|
|
79
|
+
const requestObj = {
|
|
80
|
+
'?xml': { '@_version': '1.0', '@_encoding': 'utf-8' },
|
|
81
|
+
'soap:Envelope': {
|
|
82
|
+
'@_xmlns:soap': 'http://schemas.xmlsoap.org/soap/envelope/',
|
|
83
|
+
'@_xmlns': 'http://www.datex-soft.com/soap/',
|
|
84
|
+
'soap:Body': {
|
|
85
|
+
'CallMethod': {
|
|
86
|
+
'LibName': lib,
|
|
87
|
+
'MethodName': method,
|
|
88
|
+
'ArgData': argData,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return soapBuilder.build(requestObj);
|
|
94
|
+
}
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Response Parsing
|
|
97
|
+
// ============================================================================
|
|
98
|
+
/**
|
|
99
|
+
* Рекурсивно ищет элемент по имени в распарсенном XML-объекте.
|
|
100
|
+
* Аналог SimpleXmlParser.findElement — ищет ключ на любом уровне вложенности.
|
|
101
|
+
*
|
|
102
|
+
* @param obj - распарсенный XML-объект
|
|
103
|
+
* @param key - имя искомого элемента
|
|
104
|
+
* @returns найденный элемент или undefined
|
|
105
|
+
*/
|
|
106
|
+
function findElement(obj, key) {
|
|
107
|
+
if (!obj || typeof obj !== 'object')
|
|
108
|
+
return undefined;
|
|
109
|
+
const record = obj;
|
|
110
|
+
if (key in record)
|
|
111
|
+
return record[key];
|
|
112
|
+
for (const val of Object.values(record)) {
|
|
113
|
+
const found = findElement(val, key);
|
|
114
|
+
if (found !== undefined)
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Парсит значение элемента на основе VALUE-TYPE атрибута.
|
|
121
|
+
*
|
|
122
|
+
* fast-xml-parser представляет элемент с атрибутами как объект:
|
|
123
|
+
* - `@_VALUE-TYPE` — тип значения
|
|
124
|
+
* - `#text` — текстовое содержимое (может отсутствовать для пустых элементов)
|
|
125
|
+
* - остальные ключи — дочерние элементы (для Object/Array типов)
|
|
126
|
+
*
|
|
127
|
+
* @param element - распарсенный XML-элемент
|
|
128
|
+
* @returns JS-значение соответствующего типа
|
|
129
|
+
*/
|
|
130
|
+
function parseResultValue(element) {
|
|
131
|
+
if (element === undefined || element === null)
|
|
132
|
+
return undefined;
|
|
133
|
+
// Если элемент — примитив (нет атрибутов), вернуть как есть
|
|
134
|
+
if (typeof element !== 'object')
|
|
135
|
+
return element;
|
|
136
|
+
const valueType = element['@_VALUE-TYPE'];
|
|
137
|
+
if (!valueType || valueType === 'undefined' || valueType === 'null') {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
if (valueType === 'string') {
|
|
141
|
+
const text = element['#text'];
|
|
142
|
+
return text != null ? String(text) : '';
|
|
143
|
+
}
|
|
144
|
+
if (valueType === 'integer') {
|
|
145
|
+
return parseInt(String(element['#text'] ?? '0'), 10);
|
|
146
|
+
}
|
|
147
|
+
if (valueType === 'real') {
|
|
148
|
+
return parseFloat(String(element['#text'] ?? '0'));
|
|
149
|
+
}
|
|
150
|
+
if (valueType === 'bool') {
|
|
151
|
+
return (element['#text'] ?? 0) == 1;
|
|
152
|
+
}
|
|
153
|
+
if (valueType === 'Object') {
|
|
154
|
+
return parseResultObject(element);
|
|
155
|
+
}
|
|
156
|
+
if (valueType === 'Array') {
|
|
157
|
+
return parseResultArray(element);
|
|
158
|
+
}
|
|
159
|
+
throw new WshcmException(`Unexpected VALUE-TYPE: ${valueType}`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Парсит объект из распарсенного XML-элемента.
|
|
163
|
+
* Итерирует дочерние ключи (исключая `@_*` атрибуты и `#text`),
|
|
164
|
+
* рекурсивно разбирая каждый через {@link parseResultValue}.
|
|
165
|
+
*
|
|
166
|
+
* @param element - элемент с `@_VALUE-TYPE="Object"`
|
|
167
|
+
* @returns JS-объект
|
|
168
|
+
*/
|
|
169
|
+
function parseResultObject(element) {
|
|
170
|
+
const result = {};
|
|
171
|
+
for (const [key, value] of Object.entries(element)) {
|
|
172
|
+
if (key.startsWith('@_') || key === '#text')
|
|
173
|
+
continue;
|
|
174
|
+
result[key] = parseResultValue(value);
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Парсит массив из распарсенного XML-элемента.
|
|
180
|
+
* Итерирует дочерние ключи (исключая `@_*` атрибуты и `#text`),
|
|
181
|
+
* рекурсивно разбирая каждый через {@link parseResultValue}.
|
|
182
|
+
*
|
|
183
|
+
* @param element - элемент с `@_VALUE-TYPE="Array"`
|
|
184
|
+
* @returns JS-массив
|
|
185
|
+
*/
|
|
186
|
+
function parseResultArray(element) {
|
|
187
|
+
const result = [];
|
|
188
|
+
for (const [key, value] of Object.entries(element)) {
|
|
189
|
+
if (key.startsWith('@_') || key === '#text')
|
|
190
|
+
continue;
|
|
191
|
+
result.push(parseResultValue(value));
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Парсит SOAP-ответ и извлекает результат.
|
|
197
|
+
*
|
|
198
|
+
* Использует `fast-xml-parser` с `removeNSPrefix: true` для прозрачной
|
|
199
|
+
* работы с namespace-префиксами (soap:Envelope → Envelope и т.д.).
|
|
200
|
+
*
|
|
201
|
+
* @param responseText - XML-строка ответа
|
|
202
|
+
* @returns результат выполнения метода
|
|
203
|
+
*/
|
|
204
|
+
export function parseResponse(responseText) {
|
|
205
|
+
const parsed = soapParser.parse(responseText);
|
|
206
|
+
// Проверяем на ошибку SOAP Fault
|
|
207
|
+
const fault = findElement(parsed, 'Fault');
|
|
208
|
+
if (fault && typeof fault === 'object') {
|
|
209
|
+
const errorCode = fault.faultcode ?? 'UNKNOWN';
|
|
210
|
+
const errorMessage = fault.faultstring ?? 'Unknown error';
|
|
211
|
+
throw new WshcmException(`ERR: (${errorCode}) ${errorMessage}`);
|
|
212
|
+
}
|
|
213
|
+
// Ищем ResultData
|
|
214
|
+
const resultData = findElement(parsed, 'ResultData');
|
|
215
|
+
if (!resultData) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
// Ищем Result внутри ResultData
|
|
219
|
+
const result = typeof resultData === 'object' ? resultData.Result : undefined;
|
|
220
|
+
if (result === undefined) {
|
|
221
|
+
throw new WshcmException('Unexpected response: Result element not found');
|
|
222
|
+
}
|
|
223
|
+
return parseResultValue(result);
|
|
224
|
+
}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Опции для создания WSHCM-клиента
|
|
3
|
+
*/
|
|
4
|
+
export interface WshcmClientOptions {
|
|
5
|
+
/** Использовать HTTPS вместо HTTP */
|
|
6
|
+
overHttps: boolean;
|
|
7
|
+
/** Хост сервера (например, "localhost") */
|
|
8
|
+
host: string;
|
|
9
|
+
/** Порт сервера (например, 80, 8080 или "8080") */
|
|
10
|
+
port: number | string;
|
|
11
|
+
/** Имя пользователя */
|
|
12
|
+
username: string;
|
|
13
|
+
/** Пароль */
|
|
14
|
+
password: string;
|
|
15
|
+
/** Таймаут HTTP-запросов в мс (по умолчанию 30000) */
|
|
16
|
+
requestTimeout?: number;
|
|
17
|
+
}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Evaluator } from './evaluator.js';
|
|
2
|
+
/**
|
|
3
|
+
* Callback для отслеживания прогресса загрузки
|
|
4
|
+
* @param uploadedBytes - количество загруженных байт
|
|
5
|
+
* @param totalBytes - общий размер всех файлов
|
|
6
|
+
*/
|
|
7
|
+
export type UploadProgressCallback = (uploadedBytes: number, totalBytes: number) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Опции для создания загрузчика
|
|
10
|
+
*/
|
|
11
|
+
export interface WshcmUploaderOptions {
|
|
12
|
+
/** Evaluator для выполнения кода на сервере (lifecycle управляется снаружи) */
|
|
13
|
+
evaluator: Evaluator;
|
|
14
|
+
/** Путь к загружаемому файлу или директории */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Путь назначения на сервере */
|
|
17
|
+
destination: string;
|
|
18
|
+
/** Размер чанка в байтах (по умолчанию 4MB) */
|
|
19
|
+
chunkSize?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Загрузчик файлов для WSHCM
|
|
23
|
+
*
|
|
24
|
+
* Позволяет загружать файлы и директории на сервер WebSoft HCM
|
|
25
|
+
* с разбиением на чанки и последующей сборкой на сервере.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const evaluator = client.createEvaluator();
|
|
30
|
+
* await evaluator.initialize();
|
|
31
|
+
*
|
|
32
|
+
* const uploader = new WshcmUploader({
|
|
33
|
+
* evaluator,
|
|
34
|
+
* path: './myfile.txt',
|
|
35
|
+
* destination: 'x-local://wt/uploaded/',
|
|
36
|
+
* chunkSize: 4 * 1024 * 1024 // 4MB
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* await uploader.prepare();
|
|
40
|
+
* await uploader.upload((uploaded, total) => {
|
|
41
|
+
* console.log(`Progress: ${(uploaded / total * 100).toFixed(2)}%`);
|
|
42
|
+
* });
|
|
43
|
+
* const urls = await uploader.finish();
|
|
44
|
+
*
|
|
45
|
+
* await evaluator.close();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare class WshcmUploader {
|
|
49
|
+
private evaluator;
|
|
50
|
+
private chunkSize;
|
|
51
|
+
private uploadPath;
|
|
52
|
+
private destination;
|
|
53
|
+
private isDir;
|
|
54
|
+
private isFile;
|
|
55
|
+
private isGlob;
|
|
56
|
+
private totalSize;
|
|
57
|
+
/** Базовый путь для вычисления relative paths */
|
|
58
|
+
private basePath;
|
|
59
|
+
/** Имя для finish скрипта (пустое для glob режима) */
|
|
60
|
+
private uploadName;
|
|
61
|
+
private files;
|
|
62
|
+
private destPath;
|
|
63
|
+
private tempPath;
|
|
64
|
+
private chunkPath1;
|
|
65
|
+
private chunkPath2;
|
|
66
|
+
/**
|
|
67
|
+
* Создает новый загрузчик файлов
|
|
68
|
+
*
|
|
69
|
+
* Lifecycle evaluator-а управляется вызывающим кодом —
|
|
70
|
+
* uploader не вызывает evaluator.close().
|
|
71
|
+
*/
|
|
72
|
+
constructor(options: WshcmUploaderOptions);
|
|
73
|
+
/**
|
|
74
|
+
* Подготавливает загрузку: сканирует файлы, создает структуру на сервере
|
|
75
|
+
*
|
|
76
|
+
* Поддерживаемые форматы path:
|
|
77
|
+
* - `file.txt` — загрузка одного файла
|
|
78
|
+
* - `dir/` — загрузка директории (как вложенная папка)
|
|
79
|
+
* - `dir/*` — загрузка содержимого директории (без вложенной папки)
|
|
80
|
+
*/
|
|
81
|
+
prepare(): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Загружает файлы на сервер по частям (чанкам)
|
|
84
|
+
* Мелкие чанки группируются в батчи для оптимизации
|
|
85
|
+
* @param callback - функция для отслеживания прогресса
|
|
86
|
+
*/
|
|
87
|
+
upload(callback?: UploadProgressCallback): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Финализирует загрузку: собирает чанки и перемещает файлы в целевую директорию
|
|
90
|
+
* @returns массив URL загруженных файлов
|
|
91
|
+
*/
|
|
92
|
+
finish(): Promise<string[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Сканирует директорию рекурсивно и добавляет файлы в список
|
|
95
|
+
*/
|
|
96
|
+
private scanDirectory;
|
|
97
|
+
/**
|
|
98
|
+
* Разбивает файл на чанки
|
|
99
|
+
*/
|
|
100
|
+
private chunksFromFile;
|
|
101
|
+
/**
|
|
102
|
+
* Группирует чанки в батчи для оптимизации загрузки
|
|
103
|
+
* Суммарный размер данных в батче не превышает chunkSize
|
|
104
|
+
*/
|
|
105
|
+
private batchChunks;
|
|
106
|
+
/**
|
|
107
|
+
* Загружает батч чанков одним запросом
|
|
108
|
+
* @returns размер загруженных данных в байтах
|
|
109
|
+
*/
|
|
110
|
+
private uploadBatch;
|
|
111
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
/**
|
|
5
|
+
* Описание файла для загрузки
|
|
6
|
+
*/
|
|
7
|
+
class FileDescription {
|
|
8
|
+
/** Абсолютный путь к файлу */
|
|
9
|
+
path;
|
|
10
|
+
/** Относительный путь от базовой директории */
|
|
11
|
+
relative;
|
|
12
|
+
/** MD5 хеш относительного пути (в верхнем регистре) */
|
|
13
|
+
hash;
|
|
14
|
+
/** Размер файла в байтах */
|
|
15
|
+
size;
|
|
16
|
+
constructor(filePath, basePath) {
|
|
17
|
+
this.path = filePath;
|
|
18
|
+
this.relative = path.relative(basePath, filePath).replace(/\\/g, '/');
|
|
19
|
+
this.hash = crypto.createHash('md5').update(this.relative).digest('hex').toUpperCase();
|
|
20
|
+
this.size = fs.statSync(filePath).size;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Информация о чанке файла
|
|
25
|
+
*/
|
|
26
|
+
class ChunkInfo {
|
|
27
|
+
/** Путь к файлу */
|
|
28
|
+
path;
|
|
29
|
+
/** Начало чанка в файле (байты) */
|
|
30
|
+
chunkStart;
|
|
31
|
+
/** Конец чанка в файле (байты) */
|
|
32
|
+
chunkEnd;
|
|
33
|
+
/** Хеш файла */
|
|
34
|
+
hash;
|
|
35
|
+
/** Индекс чанка */
|
|
36
|
+
index;
|
|
37
|
+
constructor(filePath, chunkStart, chunkEnd, hash, index) {
|
|
38
|
+
this.path = filePath;
|
|
39
|
+
this.chunkStart = chunkStart;
|
|
40
|
+
this.chunkEnd = chunkEnd;
|
|
41
|
+
this.hash = hash;
|
|
42
|
+
this.index = index;
|
|
43
|
+
}
|
|
44
|
+
toString() {
|
|
45
|
+
return `[ ${this.hash} : '${this.path}' : (${this.index}) ${this.chunkStart}-${this.chunkEnd} ]`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Загрузчик файлов для WSHCM
|
|
50
|
+
*
|
|
51
|
+
* Позволяет загружать файлы и директории на сервер WebSoft HCM
|
|
52
|
+
* с разбиением на чанки и последующей сборкой на сервере.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* const evaluator = client.createEvaluator();
|
|
57
|
+
* await evaluator.initialize();
|
|
58
|
+
*
|
|
59
|
+
* const uploader = new WshcmUploader({
|
|
60
|
+
* evaluator,
|
|
61
|
+
* path: './myfile.txt',
|
|
62
|
+
* destination: 'x-local://wt/uploaded/',
|
|
63
|
+
* chunkSize: 4 * 1024 * 1024 // 4MB
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* await uploader.prepare();
|
|
67
|
+
* await uploader.upload((uploaded, total) => {
|
|
68
|
+
* console.log(`Progress: ${(uploaded / total * 100).toFixed(2)}%`);
|
|
69
|
+
* });
|
|
70
|
+
* const urls = await uploader.finish();
|
|
71
|
+
*
|
|
72
|
+
* await evaluator.close();
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export class WshcmUploader {
|
|
76
|
+
evaluator;
|
|
77
|
+
chunkSize;
|
|
78
|
+
uploadPath;
|
|
79
|
+
destination;
|
|
80
|
+
isDir = false;
|
|
81
|
+
isFile = false;
|
|
82
|
+
isGlob = false;
|
|
83
|
+
totalSize = 0;
|
|
84
|
+
/** Базовый путь для вычисления relative paths */
|
|
85
|
+
basePath = '';
|
|
86
|
+
/** Имя для finish скрипта (пустое для glob режима) */
|
|
87
|
+
uploadName = '';
|
|
88
|
+
files = [];
|
|
89
|
+
// Пути на сервере
|
|
90
|
+
destPath = '';
|
|
91
|
+
tempPath = '';
|
|
92
|
+
chunkPath1 = '';
|
|
93
|
+
chunkPath2 = '';
|
|
94
|
+
/**
|
|
95
|
+
* Создает новый загрузчик файлов
|
|
96
|
+
*
|
|
97
|
+
* Lifecycle evaluator-а управляется вызывающим кодом —
|
|
98
|
+
* uploader не вызывает evaluator.close().
|
|
99
|
+
*/
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.evaluator = options.evaluator;
|
|
102
|
+
this.uploadPath = options.path;
|
|
103
|
+
this.destination = options.destination;
|
|
104
|
+
this.chunkSize = options.chunkSize || 4 * 1024 * 1024; // 4MB по умолчанию
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Подготавливает загрузку: сканирует файлы, создает структуру на сервере
|
|
108
|
+
*
|
|
109
|
+
* Поддерживаемые форматы path:
|
|
110
|
+
* - `file.txt` — загрузка одного файла
|
|
111
|
+
* - `dir/` — загрузка директории (как вложенная папка)
|
|
112
|
+
* - `dir/*` — загрузка содержимого директории (без вложенной папки)
|
|
113
|
+
*/
|
|
114
|
+
async prepare() {
|
|
115
|
+
// Проверяем glob-паттерн (dir/* или dir\*)
|
|
116
|
+
if (this.uploadPath.endsWith('/*') || this.uploadPath.endsWith('\\*')) {
|
|
117
|
+
this.isGlob = true;
|
|
118
|
+
const dirPath = this.uploadPath.slice(0, -2);
|
|
119
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
120
|
+
throw new Error(`Glob pattern requires existing directory: ${dirPath}`);
|
|
121
|
+
}
|
|
122
|
+
this.basePath = dirPath;
|
|
123
|
+
this.uploadName = ''; // пустое имя сигнализирует glob-режим на сервере
|
|
124
|
+
this.scanDirectory(dirPath, dirPath);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Обычный режим: файл или директория
|
|
128
|
+
const stats = fs.statSync(this.uploadPath);
|
|
129
|
+
if (stats.isDirectory()) {
|
|
130
|
+
this.isDir = true;
|
|
131
|
+
this.basePath = path.dirname(this.uploadPath);
|
|
132
|
+
this.uploadName = path.basename(this.uploadPath);
|
|
133
|
+
this.scanDirectory(this.uploadPath, this.basePath);
|
|
134
|
+
}
|
|
135
|
+
else if (stats.isFile()) {
|
|
136
|
+
this.isFile = true;
|
|
137
|
+
this.basePath = path.dirname(this.uploadPath);
|
|
138
|
+
this.uploadName = path.basename(this.uploadPath);
|
|
139
|
+
const fileDesc = new FileDescription(this.uploadPath, this.basePath);
|
|
140
|
+
this.files.push(fileDesc);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
throw new Error('Path is not a file or directory');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Сортируем файлы по размеру (от меньших к большим) для эффективного батчинга
|
|
147
|
+
this.files.sort((a, b) => a.size - b.size);
|
|
148
|
+
// Вычисляем общий размер
|
|
149
|
+
this.totalSize = this.files.reduce((sum, file) => sum + file.size, 0);
|
|
150
|
+
// Читаем скрипт подготовки
|
|
151
|
+
const prepareScript = fs.readFileSync(path.join(import.meta.dirname, '..', 'resources', 'upload_prepare.bs'), 'utf-8');
|
|
152
|
+
// Заменяем переменную
|
|
153
|
+
const script = prepareScript.replace('{{destination}}', this.destination);
|
|
154
|
+
// Выполняем на сервере
|
|
155
|
+
const response = await this.evaluator.eval(script);
|
|
156
|
+
// Сохраняем пути
|
|
157
|
+
this.destPath = response.dest_path.replace(/\\/g, '\\\\');
|
|
158
|
+
this.tempPath = response.temp_path.replace(/\\/g, '\\\\');
|
|
159
|
+
const [part1, part2] = response.chunk_path.split('RELATIVE_PATH_HASH');
|
|
160
|
+
this.chunkPath1 = part1.replace(/\\/g, '\\\\');
|
|
161
|
+
this.chunkPath2 = part2.replace(/\\/g, '\\\\');
|
|
162
|
+
// Создаем директории для чанков на сервере
|
|
163
|
+
const obtainDirCode = this.files
|
|
164
|
+
.map(file => `ObtainDirectory('${this.chunkPath1}${file.hash}', true);`)
|
|
165
|
+
.join('\n');
|
|
166
|
+
await this.evaluator.eval(obtainDirCode);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Загружает файлы на сервер по частям (чанкам)
|
|
170
|
+
* Мелкие чанки группируются в батчи для оптимизации
|
|
171
|
+
* @param callback - функция для отслеживания прогресса
|
|
172
|
+
*/
|
|
173
|
+
async upload(callback) {
|
|
174
|
+
const chunks = [];
|
|
175
|
+
// Создаем список всех чанков (файлы уже отсортированы по размеру)
|
|
176
|
+
for (const file of this.files) {
|
|
177
|
+
this.chunksFromFile(file, chunks);
|
|
178
|
+
}
|
|
179
|
+
// Группируем чанки в батчи
|
|
180
|
+
const batches = this.batchChunks(chunks);
|
|
181
|
+
let uploadedBytes = 0;
|
|
182
|
+
// Загружаем батчи
|
|
183
|
+
for (const batch of batches) {
|
|
184
|
+
uploadedBytes += await this.uploadBatch(batch);
|
|
185
|
+
if (callback) {
|
|
186
|
+
callback(uploadedBytes, this.totalSize);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Финализирует загрузку: собирает чанки и перемещает файлы в целевую директорию
|
|
192
|
+
* @returns массив URL загруженных файлов
|
|
193
|
+
*/
|
|
194
|
+
async finish() {
|
|
195
|
+
// Читаем скрипт финализации
|
|
196
|
+
const finishScript = fs.readFileSync(path.join(import.meta.dirname, '..', 'resources', 'upload_finish.bs'), 'utf-8');
|
|
197
|
+
// Заменяем переменные
|
|
198
|
+
const filePaths = JSON.stringify(this.files.map(f => f.relative));
|
|
199
|
+
let script = finishScript;
|
|
200
|
+
script = script.replace('{{file_name}}', this.uploadName);
|
|
201
|
+
script = script.replace('{{dest_path}}', this.destPath);
|
|
202
|
+
script = script.replace('{{temp_path}}', this.tempPath);
|
|
203
|
+
script = script.replace('{{file_paths}}', filePaths);
|
|
204
|
+
// Выполняем на сервере
|
|
205
|
+
const response = await this.evaluator.eval(script);
|
|
206
|
+
if (response.error) {
|
|
207
|
+
throw new Error(response.message || 'Upload finish failed');
|
|
208
|
+
}
|
|
209
|
+
return response.value || [];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Сканирует директорию рекурсивно и добавляет файлы в список
|
|
213
|
+
*/
|
|
214
|
+
scanDirectory(dirPath, basePath) {
|
|
215
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
218
|
+
if (entry.isDirectory()) {
|
|
219
|
+
this.scanDirectory(fullPath, basePath);
|
|
220
|
+
}
|
|
221
|
+
else if (entry.isFile()) {
|
|
222
|
+
const fileDesc = new FileDescription(fullPath, basePath);
|
|
223
|
+
this.files.push(fileDesc);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Разбивает файл на чанки
|
|
229
|
+
*/
|
|
230
|
+
chunksFromFile(file, chunks) {
|
|
231
|
+
let index = 0;
|
|
232
|
+
for (let start = 0; start < file.size; start += this.chunkSize) {
|
|
233
|
+
const end = Math.min(start + this.chunkSize, file.size);
|
|
234
|
+
chunks.push(new ChunkInfo(file.path, start, end, file.hash, index));
|
|
235
|
+
index++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Группирует чанки в батчи для оптимизации загрузки
|
|
240
|
+
* Суммарный размер данных в батче не превышает chunkSize
|
|
241
|
+
*/
|
|
242
|
+
batchChunks(chunks) {
|
|
243
|
+
const batches = [];
|
|
244
|
+
let currentBatch = [];
|
|
245
|
+
let currentSize = 0;
|
|
246
|
+
for (const chunk of chunks) {
|
|
247
|
+
const chunkSize = chunk.chunkEnd - chunk.chunkStart;
|
|
248
|
+
// Если батч переполнится — закрываем его
|
|
249
|
+
if (currentSize + chunkSize > this.chunkSize && currentBatch.length > 0) {
|
|
250
|
+
batches.push(currentBatch);
|
|
251
|
+
currentBatch = [];
|
|
252
|
+
currentSize = 0;
|
|
253
|
+
}
|
|
254
|
+
currentBatch.push(chunk);
|
|
255
|
+
currentSize += chunkSize;
|
|
256
|
+
}
|
|
257
|
+
if (currentBatch.length > 0) {
|
|
258
|
+
batches.push(currentBatch);
|
|
259
|
+
}
|
|
260
|
+
return batches;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Загружает батч чанков одним запросом
|
|
264
|
+
* @returns размер загруженных данных в байтах
|
|
265
|
+
*/
|
|
266
|
+
async uploadBatch(batch) {
|
|
267
|
+
const statements = [];
|
|
268
|
+
let totalSize = 0;
|
|
269
|
+
for (const chunk of batch) {
|
|
270
|
+
const size = chunk.chunkEnd - chunk.chunkStart;
|
|
271
|
+
const buffer = Buffer.alloc(size);
|
|
272
|
+
const fd = await fs.promises.open(chunk.path, 'r');
|
|
273
|
+
await fd.read(buffer, 0, size, chunk.chunkStart);
|
|
274
|
+
await fd.close();
|
|
275
|
+
const encoded = buffer.toString('base64');
|
|
276
|
+
const serverPath = `${this.chunkPath1}${chunk.hash}${this.chunkPath2}_${chunk.index}`;
|
|
277
|
+
statements.push(`PutFileData('${serverPath}', Base64Decode('${encoded}'))`);
|
|
278
|
+
totalSize += size;
|
|
279
|
+
}
|
|
280
|
+
await this.evaluator.eval(statements.join('\n'));
|
|
281
|
+
return totalSize;
|
|
282
|
+
}
|
|
283
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boristype/ws-client",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "WSHCM SOAP client — клиент для работы с WebSoft HCM через SP-XML API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"types": "build/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"build",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"fast-xml-parser": "^5.3.3"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^24.7.2",
|
|
17
|
+
"typescript": "^5.9.2"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"wshcm",
|
|
21
|
+
"soap",
|
|
22
|
+
"websoft",
|
|
23
|
+
"hcm"
|
|
24
|
+
],
|
|
25
|
+
"author": "BorisType Project",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/BorisType/BorisType.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/BorisType/BorisType/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/BorisType/BorisType#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.0.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc"
|
|
43
|
+
}
|
|
44
|
+
}
|