@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 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }