flamerest 1.0.126 → 1.0.128

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/REST.d.ts ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Базовый ответ от сервера
3
+ */
4
+ export declare type BaseResponse<T> = {
5
+ type: string;
6
+ status: number;
7
+ ok: boolean;
8
+ data?: T;
9
+ errors: object | undefined;
10
+ message: string | undefined;
11
+ };
12
+ /**
13
+ * Часть ответа с пагинацией
14
+ */
15
+ export declare type Paginated = {
16
+ pages: {
17
+ page: number;
18
+ perPage: number;
19
+ count: number;
20
+ total: number;
21
+ };
22
+ };
23
+ /**
24
+ * Стандартный ответ от Request с несколькими строками
25
+ */
26
+ export declare type Rows<T> = BaseResponse<Array<T>> & Paginated;
27
+ export declare type Row<T> = BaseResponse<T> & Paginated;
28
+ export declare type CustomRequest<T> = BaseResponse<T>;
29
+ export declare type CustomAnyRequest = BaseResponse<any>;
30
+ /**
31
+ * Стандартный ответ от Request с изменённой строкой
32
+ */
33
+ export declare type SavedObject<T> = BaseResponse<T>;
34
+ /**
35
+ * Элемент баланса пользователя
36
+ */
37
+ export interface BalanceItem {
38
+ name: string;
39
+ balance_decimal: string;
40
+ value: number;
41
+ }
42
+ /**
43
+ * Результат авторизации
44
+ */
45
+ export declare type Authorized = {
46
+ isAuthorized: boolean;
47
+ User: {
48
+ avatar: string;
49
+ country: string;
50
+ id: number;
51
+ lang: string;
52
+ name: string;
53
+ role: string;
54
+ user_hash?: string;
55
+ balance: 0;
56
+ balance_all: {
57
+ [key: string]: BalanceItem;
58
+ };
59
+ };
60
+ errors: {
61
+ [key: string]: any;
62
+ };
63
+ token: string;
64
+ };
65
+ export declare class FLAMEREST {
66
+ /**
67
+ * Адрес серва
68
+ */
69
+ SERVER: string;
70
+ /**
71
+ * Стандартное число запросов на страницу
72
+ */
73
+ perPageDefault: number;
74
+ /**
75
+ * Будет вызван, если любой из запросов вернут требование авторизоваться
76
+ */
77
+ unauthorized_callback: (() => void) | undefined;
78
+ /**
79
+ * Будет вызван, если сервер вернёт статус 503 (Service Unavailable / Maintenance)
80
+ * Может содержать сообщение об ошибке из JSON ответа
81
+ */
82
+ maintenance_callback: ((errorMessage?: string) => void) | undefined;
83
+ /**
84
+ * Токен приложения конкретного клиента для отправки пуш уведомлений именно ему
85
+ */
86
+ pushNotificationToken: string | null;
87
+ /**
88
+ * Версия api
89
+ */
90
+ version: string;
91
+ /**
92
+ * Версия кода клиента (отправляется в заголовке каждого запроса).
93
+ * Можно установить извне: REST.VERSION_CODE = "1.2.3"
94
+ */
95
+ VERSION_CODE: string;
96
+ /**
97
+ * Если авторизация по токену, то он сюда подставляется
98
+ */
99
+ isAuthByJWTQuery: boolean;
100
+ /**
101
+ * Режим авторизации: Bearer|Link
102
+ */
103
+ authMode: "Bearer" | "Link";
104
+ token: string | null;
105
+ /**
106
+ * Конструктор класса FLAMEREST
107
+ * @param {string} server_address Адрес сервера (опционально)
108
+ * @param {string} localhost_endpoint Альтернативный адрес для localhost (опционально)
109
+ * @param {Function} unauthorized_callback Колбек для обработки 401 ошибок (опционально)
110
+ * @param {string} version Версия API (по умолчанию 'v1')
111
+ * @param {Function} maintenance_callback Колбек для обработки 503 ошибок (опционально)
112
+ */
113
+ constructor(server_address?: string, localhost_endpoint?: string, unauthorized_callback?: () => void, version?: string, maintenance_callback?: (errorMessage?: string) => void);
114
+ /**
115
+ * Установка плагина для Vue
116
+ * @param Vue
117
+ * @param options
118
+ */
119
+ install(Vue: any, options: any): void;
120
+ /**
121
+ *
122
+ * @param {string} url Адрес
123
+ * @param {object|string|FormData} params Параметры, которые надо передать, могут быть в виде объекта или строки
124
+ * @param {string} type Тип
125
+ * @param {string} responseType Тип ответа: json или blob
126
+ * @param {boolean} isNeedToken нужен ли токен для запроса
127
+ * @param {Object} customHeaders объект с доп заголовками, которые надо включить в запрос
128
+ */
129
+ request(url: string, params: object | string | FormData, type?: 'GET' | 'POST' | 'PUT' | 'DELETE', responseType?: 'json' | 'blob', isNeedToken?: boolean, customHeaders?: Record<string, string>): Promise<BaseResponse<any> & Partial<Paginated>>;
130
+ /**
131
+ * Получить выборку из таблицы через REST
132
+ * @param {string} table
133
+ * @param {object | string | null} where Позволяет делать выборку из связанных таблиц, надо только их указать через название таблицы sites.id=5, и указать колонку в expand
134
+ * @param {object | Array<string> | null} extfields
135
+ * @param {object | Array<string> | string | null} fields
136
+ * @param {object | Array<string> | string | null} sortfields
137
+ * @param {number | undefined} page
138
+ * @param {number | undefined} perPage
139
+ * @param {boolean | undefined} RemoveDuplicates
140
+ * @param {any} titles Это чтобы мы могли контроллить какие названия полей мы будет загружать при экспорте, чтобы они были как в таблице
141
+ * @param {any} tree дерево
142
+ * @param {any} params Доп параметры для кастомизации запроса на беке
143
+ * @param {any} exportData имя файла для экспорта
144
+ * @return Promise<Rows<T>>
145
+ */
146
+ get<T>(table: string, where?: object | string | null, extfields?: object | Array<string> | null, fields?: object | Array<string> | string | null, sortfields?: object | Array<string> | string | null, page?: number, perPage?: number, RemoveDuplicates?: boolean, format?: any, titles?: any, tree?: any, params?: any, exportData?: any): Promise<Rows<T>>;
147
+ /**
148
+ * Получить все записи по запросу [постранично]
149
+ * @param {string} table
150
+ * @param {object} params
151
+ * @returns {Promise<Rows<T>>}
152
+ */
153
+ all<T>(table: string, params?: {
154
+ where?: object;
155
+ extfields?: object | Array<string>;
156
+ fields?: object | Array<string>;
157
+ sort?: Array<string>;
158
+ page?: number;
159
+ perPage?: number;
160
+ tree?: number;
161
+ params?: any;
162
+ export?: {
163
+ format?: 'xlsx' | 'csv';
164
+ titles?: Array<string>;
165
+ filename?: string;
166
+ };
167
+ }): Promise<Rows<T>>;
168
+ /**
169
+ * Получить одну запись по ID или по условию выборки [первая запись]
170
+ * @param {string} table
171
+ * @param {number|string|object} IDOrWhere
172
+ * @param {object|Array} extfields
173
+ * @param {object|Array} fields
174
+ * @param {string} primaryKeyName если указан ID, то указать название первичного ключа, если от id он отличается
175
+ */
176
+ one<T>(table: string, IDOrWhere: number | string | object, extfields?: object | Array<string> | null, fields?: object | Array<string> | null, primaryKeyName?: string): Promise<T | null>;
177
+ /**
178
+ * Создать новую запись
179
+ * @param {string} table
180
+ * @param {object} values
181
+ * @param {number|string|null} appendTo
182
+ * @param {number|string|null} insertAfter
183
+ * @param {boolean|null} insertFirst
184
+ */
185
+ create<T>(table: string, values: object, appendTo?: number | string | null, insertAfter?: number | string | null, insertFirst?: number | string | null): Promise<SavedObject<T>>;
186
+ /**
187
+ * Удалить запись
188
+ * @param {string} table
189
+ * @param {number|string} id
190
+ * @param {object|null} byFields Если указан, удаляет по этим параметрам
191
+ */
192
+ remove(table: string, id?: number | string, byFields?: object | null): Promise<boolean | any>;
193
+ /**
194
+ * Редактировать значения
195
+ * @param {string} table
196
+ * @param {number|string} ID
197
+ * @param {object} values
198
+ * @param {number|string|null} appendTo
199
+ * @param {number|string|null} insertAfter
200
+ * @param {boolean|null} insertFirst
201
+ */
202
+ edit<T>(table: string, ID: number | string, values: object, appendTo?: number | string | null, insertAfter?: number | string | null, insertFirst?: number | string | null): Promise<SavedObject<T>>;
203
+ /**
204
+ * Получить схемы всех таблиц
205
+ */
206
+ getCRUDInfo(): Promise<object>;
207
+ /**
208
+ * Авторизоваться
209
+ * @param {string|undefined} username
210
+ * @param {string|undefined} password
211
+ * @param {object|string|null} pushNotificationToken
212
+ */
213
+ auth(username?: string, password?: string, pushNotificationToken?: object | string | null): Promise<Authorized>;
214
+ /**
215
+ * Зарегистрироваться с этим логином и паролем
216
+ * @param {string|null} email
217
+ * @param {string|null} username
218
+ * @param {string} password
219
+ * @param {string|null} name
220
+ * @param {object|string|null} pushNotificationToken
221
+ * @param {any} data
222
+ */
223
+ signup(email: string | null, username: string | null, password: string, name?: string | null, pushNotificationToken?: object | string | null, data?: any): Promise<Authorized>;
224
+ /**
225
+ * Выйти из системы
226
+ */
227
+ logout(): Promise<object>;
228
+ /**
229
+ * Восстановление пароля
230
+ * Запрос на восстановление пароля
231
+ * @param {*} email
232
+ * @returns
233
+ */
234
+ ResetPasswordRequest(email: string): Promise<any>;
235
+ /**
236
+ * Восстановление пароля
237
+ * Проверка токена восстановления
238
+ * @param {*} token токен подтверждения
239
+ * @returns
240
+ */
241
+ ResetPasswordTokenCheck(token: string): Promise<any>;
242
+ /**
243
+ * Восстановление пароля
244
+ * Сохранение нового пароля
245
+ * @param {*} token
246
+ * @param {*} password
247
+ * @returns
248
+ */
249
+ ResetPasswordSaveNewPassword(token: string, password: string): Promise<any>;
250
+ /**
251
+ * Подготовить объект под загрузку: загрузить данные из элементов Input [type=file] / Clipboard / DataTransfer [Drag&Drop/Clipboard]
252
+ * @param {object} values
253
+ */
254
+ prepare(values: {
255
+ [key: string]: any;
256
+ }, asFormData?: boolean): Promise<any>;
257
+ /**
258
+ * Прочесть файл асинхронно
259
+ * @param {File} file
260
+ * @param {'data' | 'text'} readAs
261
+ * @returns {Promise<string>}
262
+ */
263
+ readFileAsync(file: File, readAs?: 'data' | 'text'): Promise<string | ArrayBuffer | null>;
264
+ /**
265
+ * Заполнить существующий объект пришедшими из БД данными
266
+ * сохраняя при этом оригинальные классы и функции
267
+ * @param {*} object
268
+ * @param {*} values
269
+ */
270
+ fillObject<T extends object>(object: T, values: any): T;
271
+ generateID(length: number): string;
272
+ /**
273
+ * Вычисляет на лету детерминированную строку, которая будет использоваться как часть ключа для подписи.
274
+ * Ключ генерируется на основе последовательности числовых преобразований, что делает его неочевидным
275
+ * при статическом анализе кода. Этот метод всегда возвращает один и тот же результат.
276
+ * @returns {string} Детерминированно сгенерированная строка.
277
+ */
278
+ getDeterministicKeyPart(): string;
279
+ /**
280
+ * Создаёт подпись для запроса на основе его параметров, используя лучшие практики.
281
+ * Собирает все GET и POST параметры, сортирует их и формирует каноническую строку для хеширования.
282
+ * @param url URL запроса, включая GET-параметры.
283
+ * @param params POST-параметры запроса (в виде объекта).
284
+ * @returns Promise<string> Строка подписи.
285
+ */
286
+ private _generateRequestSignature;
287
+ /**
288
+ *
289
+ * @param {string} data
290
+ * @param {string} key
291
+ * @returns
292
+ */
293
+ hmac_sha256(message: BodyInit | null | undefined, secret_key: string): Promise<string>;
294
+ }
295
+ /**
296
+ * Экземпляр-синглтон класса FLAMEREST.
297
+ * Используйте его для всех запросов в приложении.
298
+ * `import REST from './REST.ts';`
299
+ */
300
+ declare const REST: FLAMEREST;
301
+ export default REST;
package/REST.js ADDED
@@ -0,0 +1,810 @@
1
+ export class FLAMEREST {
2
+ /**
3
+ * Конструктор класса FLAMEREST
4
+ * @param {string} server_address Адрес сервера (опционально)
5
+ * @param {string} localhost_endpoint Альтернативный адрес для localhost (опционально)
6
+ * @param {Function} unauthorized_callback Колбек для обработки 401 ошибок (опционально)
7
+ * @param {string} version Версия API (по умолчанию 'v1')
8
+ * @param {Function} maintenance_callback Колбек для обработки 503 ошибок (опционально)
9
+ */
10
+ constructor(server_address, localhost_endpoint, unauthorized_callback, version, maintenance_callback) {
11
+ /**
12
+ * Стандартное число запросов на страницу
13
+ */
14
+ this.perPageDefault = 20;
15
+ /**
16
+ * Токен приложения конкретного клиента для отправки пуш уведомлений именно ему
17
+ */
18
+ this.pushNotificationToken = null;
19
+ /**
20
+ * Версия кода клиента (отправляется в заголовке каждого запроса).
21
+ * Можно установить извне: REST.VERSION_CODE = "1.2.3"
22
+ */
23
+ this.VERSION_CODE = "1.0.0";
24
+ /**
25
+ * Если авторизация по токену, то он сюда подставляется
26
+ */
27
+ this.isAuthByJWTQuery = true;
28
+ /**
29
+ * Режим авторизации: Bearer|Link
30
+ */
31
+ this.authMode = "Bearer";
32
+ this.token = null;
33
+ if (typeof window === 'undefined') {
34
+ // SSR: используем предоставленный адрес или дефолтный
35
+ this.SERVER = server_address ?? "http://localhost/";
36
+ }
37
+ else {
38
+ // Client: определяем адрес по window.location или из параметра
39
+ if (server_address === undefined) {
40
+ this.SERVER = window.location.protocol + "//" + window.location.host;
41
+ }
42
+ else {
43
+ this.SERVER = server_address;
44
+ }
45
+ if (window.location.hostname === 'localhost' && localhost_endpoint !== undefined) {
46
+ this.SERVER = localhost_endpoint;
47
+ }
48
+ }
49
+ // Убираем слэш в конце, если он есть
50
+ this.SERVER = this.SERVER.endsWith('/') ? this.SERVER.substring(0, this.SERVER.length - 1) : this.SERVER;
51
+ this.unauthorized_callback = unauthorized_callback;
52
+ this.maintenance_callback = maintenance_callback;
53
+ this.version = version ?? 'v1';
54
+ }
55
+ /**
56
+ * Установка плагина для Vue
57
+ * @param Vue
58
+ * @param options
59
+ */
60
+ install(Vue, options) {
61
+ if (typeof window !== 'undefined') {
62
+ window.REST = this;
63
+ }
64
+ }
65
+ /**
66
+ *
67
+ * @param {string} url Адрес
68
+ * @param {object|string|FormData} params Параметры, которые надо передать, могут быть в виде объекта или строки
69
+ * @param {string} type Тип
70
+ * @param {string} responseType Тип ответа: json или blob
71
+ * @param {boolean} isNeedToken нужен ли токен для запроса
72
+ * @param {Object} customHeaders объект с доп заголовками, которые надо включить в запрос
73
+ */
74
+ async request(url, params, type = 'GET', responseType = 'json', isNeedToken = true, customHeaders = {}) {
75
+ // Подставляем сервер автоматом если URL не содержит протокол
76
+ // Не трогаем URL с явным протоколом (http://, https://) или протокол-независимые (//)
77
+ if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('//')) {
78
+ // Убираем начальный "/" если он есть, т.к. SERVER уже без слэша в конце
79
+ if (url[0] === '/') {
80
+ url = url.substring(1);
81
+ }
82
+ // Добавляем SERVER с разделителем "/"
83
+ url = this.SERVER + '/' + url;
84
+ }
85
+ // Создаём подпись запроса до его модификации
86
+ const SIGN = await this._generateRequestSignature(url, params);
87
+ // Нормализуем параметры, если они есть
88
+ if (typeof params === "object" && params !== null) {
89
+ if (!(params instanceof FormData)) {
90
+ params = JSON.stringify(params);
91
+ }
92
+ if (type === 'GET') {
93
+ type = 'POST';
94
+ }
95
+ responseType = 'json';
96
+ }
97
+ const that = this;
98
+ // Фетч поддерживается - получаем через него, это быстрее
99
+ if (typeof fetch === "function") {
100
+ return new Promise(async (resolve, reject) => {
101
+ try {
102
+ // Авторизация
103
+ if (this.isAuthByJWTQuery && isNeedToken === true && this.token !== null && this.token !== undefined && this.token !== 'undefined') {
104
+ switch (this.authMode) {
105
+ case 'Link':
106
+ url = url + (url.indexOf("?") === -1 ? "?" : "&") + "access-token=" + this.token;
107
+ break;
108
+ default:
109
+ Object.assign(customHeaders, { 'Authorization': 'Bearer ' + this.token });
110
+ break;
111
+ }
112
+ }
113
+ // Уникальный user_hash (только на клиенте)
114
+ let user_hash = null;
115
+ if (typeof window !== 'undefined' && window.localStorage) {
116
+ user_hash = window.localStorage.getItem('user_hash');
117
+ if (user_hash) {
118
+ Object.assign(customHeaders, { udata: user_hash });
119
+ }
120
+ }
121
+ // Тело запроса
122
+ const headers = { ...customHeaders };
123
+ if (!(params instanceof FormData)) {
124
+ headers['Content-type'] = 'application/json; charset=utf-8';
125
+ }
126
+ // Версия кода клиента — уходит с каждым запросом
127
+ headers['version'] = this.VERSION_CODE;
128
+ const requestBody = {
129
+ method: type,
130
+ mode: 'cors',
131
+ headers: headers
132
+ };
133
+ if (type !== 'GET') {
134
+ // Принимает и formData тоже и чистую json строку
135
+ requestBody.body = params;
136
+ }
137
+ // Добавляем подпись в заголовки
138
+ requestBody.headers['sign'] = SIGN;
139
+ // Делаем запрос
140
+ const response = await fetch(url, requestBody);
141
+ // Тело ответа формируется в два этапа: сперва заголовки, затем ответ
142
+ const ResolveBody = {
143
+ status: response.status,
144
+ ok: response.ok
145
+ };
146
+ // Ответ с ошибкой
147
+ if (!response.ok) {
148
+ // Тело ошибки
149
+ ResolveBody.message = response.statusText;
150
+ try {
151
+ ResolveBody.errors = await response.json();
152
+ // Ошибки
153
+ switch (ResolveBody.status) {
154
+ // Ошибка валидации: Собираем все ошибки полей
155
+ case 422:
156
+ const Errs = {};
157
+ for (const err of ResolveBody.errors) {
158
+ Errs[err['field']] = Errs[err['field']] === undefined ? err['message'] : Errs[err['field']] + ". " + err['message'];
159
+ }
160
+ ResolveBody.errors = Errs;
161
+ break;
162
+ case 401:
163
+ if (typeof this.unauthorized_callback === 'function') {
164
+ this.unauthorized_callback();
165
+ }
166
+ break;
167
+ case 503:
168
+ // Обработка режима обслуживания (maintenance)
169
+ let maintenanceMessage = undefined;
170
+ if (ResolveBody.errors && typeof ResolveBody.errors === 'object' && 'error' in ResolveBody.errors) {
171
+ maintenanceMessage = ResolveBody.errors.error;
172
+ }
173
+ if (typeof this.maintenance_callback === 'function') {
174
+ this.maintenance_callback(maintenanceMessage);
175
+ }
176
+ break;
177
+ }
178
+ }
179
+ catch (exjson) {
180
+ ResolveBody.errors = await response.text();
181
+ }
182
+ // Рапортуем об ошибке
183
+ console.error('Ошибка загрузки [' + response.status + '] ' + url + ": " + response.statusText, ResolveBody, ResolveBody.errors);
184
+ resolve(ResolveBody);
185
+ return;
186
+ }
187
+ // Загрузка успешна
188
+ // Если в заголовках указана паджинация
189
+ let pages = undefined;
190
+ if (response.headers.get('X-Pagination-Current-Page') !== null) {
191
+ pages = {
192
+ page: parseInt(response.headers.get('X-Pagination-Current-Page')),
193
+ perPage: parseInt(response.headers.get('X-Pagination-Per-Page')),
194
+ count: parseInt(response.headers.get('X-Pagination-Page-Count')),
195
+ total: parseInt(response.headers.get('X-Pagination-Total-Count')),
196
+ };
197
+ }
198
+ // Заполняем тело ответа заголовками
199
+ ResolveBody.type = "json";
200
+ ResolveBody.data = {};
201
+ ResolveBody.pages = pages;
202
+ // Получаем тело ответа
203
+ switch (responseType) {
204
+ case 'json':
205
+ ResolveBody.data = await response.text();
206
+ break;
207
+ case 'blob': {
208
+ // Записываем имя файла и mime-тип
209
+ ResolveBody.filename = 'file'; // TODO: response.headers.get('content-disposition').split('filename=')[1];
210
+ ResolveBody.MimeType = response.headers.get('content-Type');
211
+ ResolveBody.data = await response.blob();
212
+ // Если ответ в виде блоба, сразу его отдаём без декодировки
213
+ resolve(ResolveBody);
214
+ return;
215
+ }
216
+ }
217
+ // Декодируем тело ответа, если оно есть
218
+ if (ResolveBody.data === undefined) {
219
+ ResolveBody.errors = ["Принятый ответ пуст"];
220
+ console.error("Принятый ответ пуст", ResolveBody);
221
+ resolve(ResolveBody);
222
+ return;
223
+ }
224
+ ;
225
+ // Пустой ответ конвертируем в валидный пустой объект
226
+ if (ResolveBody.data === "")
227
+ ResolveBody.data = "{}";
228
+ // Декодируем
229
+ try {
230
+ ResolveBody.data = JSON.parse(ResolveBody.data);
231
+ }
232
+ catch (ex) {
233
+ ResolveBody.errors = ["Ошибка декодирования"];
234
+ console.error("Ошибка декодирования", ResolveBody);
235
+ reject(ResolveBody);
236
+ return;
237
+ }
238
+ // Если пришёл ответ: неавторизовано, и указан коллбек авторизации - запускаем его
239
+ if (ResolveBody.data.Auth === false) {
240
+ if (that.unauthorized_callback !== undefined) {
241
+ that.unauthorized_callback();
242
+ resolve(ResolveBody);
243
+ return;
244
+ }
245
+ }
246
+ // Возвращаем успешную загрузку
247
+ resolve(ResolveBody);
248
+ }
249
+ catch (err) {
250
+ // Ошибка загрузки любого типа
251
+ // TODO: на этом этапе стоит сделать, чтобы он пробовал повторить запрос, если это GET
252
+ if (typeof err !== 'object' || err.message === undefined) {
253
+ err = {
254
+ status: 0,
255
+ message: '',
256
+ };
257
+ }
258
+ if (typeof err.body === 'object') {
259
+ err.body = await err.body;
260
+ }
261
+ console.error('Ошибка загрузки [' + 0 + '] ' + url + ": " + err.message);
262
+ reject(err);
263
+ }
264
+ });
265
+ }
266
+ else {
267
+ // Фетч не поддерживается (старые браузеры или окружения) - возвращаем промисифицированный XHR
268
+ return new Promise((resolve, reject) => {
269
+ const xhr = new XMLHttpRequest();
270
+ xhr.open(type, url, true);
271
+ xhr.responseType = 'json';
272
+ xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
273
+ // Версия кода клиента — уходит с каждым запросом (fallback без fetch)
274
+ xhr.setRequestHeader('version', this.VERSION_CODE);
275
+ xhr.withCredentials = false;
276
+ xhr.send(params);
277
+ xhr.onload = function () {
278
+ if (xhr.status !== 200) {
279
+ console.error('Ошибка загрузки [' + xhr.status + '] ' + url + ": " + xhr.statusText);
280
+ return reject({
281
+ status: xhr.status,
282
+ message: xhr.statusText
283
+ });
284
+ }
285
+ let pages = undefined;
286
+ if (xhr.getResponseHeader('X-Pagination-Current-Page') !== null) {
287
+ pages = {
288
+ page: parseInt(xhr.getResponseHeader('X-Pagination-Current-Page')),
289
+ perPage: parseInt(xhr.getResponseHeader('X-Pagination-Per-Page')),
290
+ count: parseInt(xhr.getResponseHeader('X-Pagination-Page-Count')),
291
+ total: parseInt(xhr.getResponseHeader('X-Pagination-Total-Count')),
292
+ };
293
+ }
294
+ return resolve({
295
+ status: xhr.status,
296
+ type: xhr.responseType,
297
+ data: xhr.response,
298
+ pages: pages,
299
+ ok: true,
300
+ errors: undefined,
301
+ message: ''
302
+ });
303
+ };
304
+ xhr.onerror = function () {
305
+ console.error('Ошибка загрузки [' + 0 + '] ' + url + ": Нет соединения с сервером");
306
+ return reject({
307
+ status: 0,
308
+ message: "Нет соединения с сервером"
309
+ });
310
+ };
311
+ });
312
+ }
313
+ }
314
+ /**
315
+ * Получить выборку из таблицы через REST
316
+ * @param {string} table
317
+ * @param {object | string | null} where Позволяет делать выборку из связанных таблиц, надо только их указать через название таблицы sites.id=5, и указать колонку в expand
318
+ * @param {object | Array<string> | null} extfields
319
+ * @param {object | Array<string> | string | null} fields
320
+ * @param {object | Array<string> | string | null} sortfields
321
+ * @param {number | undefined} page
322
+ * @param {number | undefined} perPage
323
+ * @param {boolean | undefined} RemoveDuplicates
324
+ * @param {any} titles Это чтобы мы могли контроллить какие названия полей мы будет загружать при экспорте, чтобы они были как в таблице
325
+ * @param {any} tree дерево
326
+ * @param {any} params Доп параметры для кастомизации запроса на беке
327
+ * @param {any} exportData имя файла для экспорта
328
+ * @return Promise<Rows<T>>
329
+ */
330
+ get(table, where, extfields, fields, sortfields, page, perPage, RemoveDuplicates, format, titles, tree, params, exportData) {
331
+ // Нормализуем имена таблиц
332
+ table = table.replace(/_/g, "");
333
+ let responseType = "json";
334
+ // Генерим запрос
335
+ const query = this.SERVER + '/api/' + this.version + '/' + table;
336
+ const json = {};
337
+ // Генерим условия
338
+ if (where !== undefined && where !== null)
339
+ json.where = where;
340
+ if (tree !== undefined && tree !== null)
341
+ json.tree = tree;
342
+ if (fields !== undefined && fields !== null)
343
+ json.fields = fields;
344
+ if (sortfields !== undefined && sortfields !== null)
345
+ json.sort = sortfields;
346
+ if (extfields !== undefined && extfields !== null)
347
+ json.extfields = extfields;
348
+ if (params !== undefined && params !== null)
349
+ json.params = params;
350
+ if (RemoveDuplicates !== undefined && RemoveDuplicates !== null)
351
+ json.RemoveDuplicates = true;
352
+ if (titles !== undefined && titles !== null)
353
+ json.titles = titles;
354
+ // экспорт
355
+ if (exportData !== undefined && exportData !== null) {
356
+ json.export = {
357
+ format: exportData.format ?? 'xlsx',
358
+ titles: exportData.titles ?? [],
359
+ filename: exportData.filename ?? 'export_' + Date.now() + ".xlsx",
360
+ };
361
+ responseType = "blob";
362
+ }
363
+ // Страницы
364
+ json['per-page'] = perPage === undefined ? this.perPageDefault : perPage;
365
+ json['page'] = page === undefined ? 1 : page;
366
+ return this.request(query, JSON.stringify(json), 'POST', responseType);
367
+ }
368
+ /**
369
+ * Получить все записи по запросу [постранично]
370
+ * @param {string} table
371
+ * @param {object} params
372
+ * @returns {Promise<Rows<T>>}
373
+ */
374
+ all(table, params) {
375
+ return this.get(table, params?.where, params?.extfields, params?.fields, params?.sort, params?.page, params?.perPage, undefined, undefined, undefined, params?.tree, params?.params, params?.export);
376
+ }
377
+ /**
378
+ * Получить одну запись по ID или по условию выборки [первая запись]
379
+ * @param {string} table
380
+ * @param {number|string|object} IDOrWhere
381
+ * @param {object|Array} extfields
382
+ * @param {object|Array} fields
383
+ * @param {string} primaryKeyName если указан ID, то указать название первичного ключа, если от id он отличается
384
+ */
385
+ async one(table, IDOrWhere, extfields = null, fields = null, primaryKeyName = 'id') {
386
+ let where = {};
387
+ if (typeof IDOrWhere === 'string' || typeof IDOrWhere === 'number')
388
+ where = { [primaryKeyName]: IDOrWhere };
389
+ else if (typeof IDOrWhere === 'object')
390
+ where = IDOrWhere;
391
+ else
392
+ throw new Error("Нужно передавать ID или объект");
393
+ const resp = await this.get(table, where, extfields, fields, null, 1, 1);
394
+ if (resp.errors)
395
+ return Promise.reject(resp);
396
+ if (!resp.data || resp.data.length === 0)
397
+ return null;
398
+ return resp.data[0];
399
+ }
400
+ /**
401
+ * Создать новую запись
402
+ * @param {string} table
403
+ * @param {object} values
404
+ * @param {number|string|null} appendTo
405
+ * @param {number|string|null} insertAfter
406
+ * @param {boolean|null} insertFirst
407
+ */
408
+ async create(table, values, appendTo = null, insertAfter = null, insertFirst = null) {
409
+ // Нормализуем имена таблиц
410
+ table = table.replace(/_/g, "");
411
+ // Подготовить значения
412
+ if (!(values instanceof FormData)) {
413
+ await this.prepare(values);
414
+ }
415
+ return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/create?'
416
+ + (appendTo !== null ? '&appendTo=' + appendTo : '')
417
+ + (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
418
+ + (insertFirst !== null ? '&insertFirst=' + insertFirst : ''), (values instanceof FormData ? values : JSON.stringify(values)), 'POST');
419
+ }
420
+ /**
421
+ * Удалить запись
422
+ * @param {string} table
423
+ * @param {number|string} id
424
+ * @param {object|null} byFields Если указан, удаляет по этим параметрам
425
+ */
426
+ async remove(table, id = 0, byFields = null) {
427
+ // Нормализуем имена таблиц
428
+ table = table.replace(/_/g, "");
429
+ let params = {};
430
+ if (byFields instanceof Object)
431
+ params = byFields;
432
+ const resp = await this.request(this.SERVER + '/api/' + this.version + '/' + table + '/delete?id=' + id, JSON.stringify(params), 'DELETE');
433
+ if (resp.status === 204)
434
+ return true;
435
+ return resp;
436
+ }
437
+ /**
438
+ * Редактировать значения
439
+ * @param {string} table
440
+ * @param {number|string} ID
441
+ * @param {object} values
442
+ * @param {number|string|null} appendTo
443
+ * @param {number|string|null} insertAfter
444
+ * @param {boolean|null} insertFirst
445
+ */
446
+ async edit(table, ID, values, appendTo = null, insertAfter = null, insertFirst = null) {
447
+ // Нормализуем имена таблиц
448
+ table = table.replace(/_/g, "");
449
+ // Подготовить значения
450
+ if (!(values instanceof FormData)) {
451
+ await this.prepare(values);
452
+ }
453
+ return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/update?id=' + ID
454
+ + (appendTo !== null ? '&appendTo=' + appendTo : '')
455
+ + (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
456
+ + (insertFirst !== null ? '&insertFirst=' + insertFirst : ''), (values instanceof FormData ? values : JSON.stringify(values)), 'POST');
457
+ }
458
+ /**
459
+ * Получить схемы всех таблиц
460
+ */
461
+ getCRUDInfo() {
462
+ if (typeof window !== 'undefined' && window.sessionStorage) {
463
+ const cachedSchema = window.sessionStorage.getItem("crudschema");
464
+ if (cachedSchema) {
465
+ return new Promise((resolve) => { resolve(JSON.parse(cachedSchema)); });
466
+ }
467
+ }
468
+ return this.request(this.SERVER + '/site/crudschema', {}, 'GET')
469
+ .then(res => {
470
+ if (typeof window !== 'undefined' && window.sessionStorage) {
471
+ // Кешируем схему в браузере на время текущей сессии (в пределах ОДНОЙ вкладки)
472
+ window.sessionStorage.setItem("crudschema", JSON.stringify(res));
473
+ }
474
+ return res;
475
+ });
476
+ }
477
+ /**
478
+ * Авторизоваться
479
+ * @param {string|undefined} username
480
+ * @param {string|undefined} password
481
+ * @param {object|string|null} pushNotificationToken
482
+ */
483
+ async auth(username, password, pushNotificationToken) {
484
+ let resp;
485
+ if (this.token && !username) {
486
+ resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({}), 'POST', 'json', true);
487
+ }
488
+ else {
489
+ resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({ login: username, password: password, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null) }), 'POST', 'json', false);
490
+ }
491
+ if (resp.errors || !resp.data) {
492
+ return Promise.reject(resp.errors ?? new Error("No data received"));
493
+ }
494
+ // после успешной авторизации устанавливаем токен
495
+ if (typeof resp.data.token === 'string')
496
+ this.token = resp.data.token;
497
+ // Сохраняем хеш юзера автоматически (только на клиенте)
498
+ if (typeof window !== 'undefined' && window.localStorage) {
499
+ const user_hash = window.localStorage.getItem('user_hash');
500
+ if (resp?.data?.User?.user_hash && resp.data.User.user_hash !== user_hash) {
501
+ window.localStorage.setItem('user_hash', resp.data.User.user_hash);
502
+ }
503
+ }
504
+ return resp.data;
505
+ }
506
+ /**
507
+ * Зарегистрироваться с этим логином и паролем
508
+ * @param {string|null} email
509
+ * @param {string|null} username
510
+ * @param {string} password
511
+ * @param {string|null} name
512
+ * @param {object|string|null} pushNotificationToken
513
+ * @param {any} data
514
+ */
515
+ async signup(email, username, password, name = null, pushNotificationToken = null, data = null) {
516
+ const resp = await this.request(this.SERVER + '/auth/signup', JSON.stringify({
517
+ login: username, email: email, password: password,
518
+ name: name, data: data, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null)
519
+ }), 'POST', 'json', false);
520
+ if (resp.errors || !resp.data) {
521
+ return Promise.reject(resp.errors ?? new Error("No data received"));
522
+ }
523
+ // после успешной авторизации устанавливаем токен
524
+ if (typeof resp.data.token === 'string')
525
+ this.token = resp.data.token;
526
+ return resp.data;
527
+ }
528
+ /**
529
+ * Выйти из системы
530
+ */
531
+ logout() {
532
+ return this.request(this.SERVER + '/auth/logout', '{}', 'POST');
533
+ }
534
+ /**
535
+ * Восстановление пароля
536
+ * Запрос на восстановление пароля
537
+ * @param {*} email
538
+ * @returns
539
+ */
540
+ async ResetPasswordRequest(email) {
541
+ const resp = await this.request(this.SERVER + '/auth/resetpasswordrequest', JSON.stringify({ email: email }), 'POST', 'json', false);
542
+ if (resp.errors || !resp.data)
543
+ return Promise.reject(resp.errors ?? new Error("No data received"));
544
+ return resp.data;
545
+ }
546
+ /**
547
+ * Восстановление пароля
548
+ * Проверка токена восстановления
549
+ * @param {*} token токен подтверждения
550
+ * @returns
551
+ */
552
+ async ResetPasswordTokenCheck(token) {
553
+ const resp = await this.request(this.SERVER + '/auth/resettokencheck', JSON.stringify({ token: token }), 'POST', 'json', false);
554
+ if (resp.errors || !resp.data)
555
+ return Promise.reject(resp.errors ?? new Error("No data received"));
556
+ return resp.data;
557
+ }
558
+ /**
559
+ * Восстановление пароля
560
+ * Сохранение нового пароля
561
+ * @param {*} token
562
+ * @param {*} password
563
+ * @returns
564
+ */
565
+ async ResetPasswordSaveNewPassword(token, password) {
566
+ const resp = await this.request(this.SERVER + '/auth/resetconfirm', JSON.stringify({ token: token, password: password }), 'POST', 'json', false);
567
+ if (resp.errors || !resp.data)
568
+ return Promise.reject(resp.errors ?? new Error("No data received"));
569
+ return resp.data;
570
+ }
571
+ /**
572
+ * Подготовить объект под загрузку: загрузить данные из элементов Input [type=file] / Clipboard / DataTransfer [Drag&Drop/Clipboard]
573
+ * @param {object} values
574
+ */
575
+ async prepare(values, asFormData = false) {
576
+ // В SSR этот метод не будет обрабатывать файлы, т.к. DOM и File API недоступны.
577
+ if (typeof window === 'undefined') {
578
+ return values;
579
+ }
580
+ // Если в один из параметров передан FileList или input[type=file], т.е. нужно загрузить файлы
581
+ const formData = new FormData();
582
+ for (let val in values) {
583
+ // Пустые значения нам не нужны
584
+ if (values[val] === undefined || values[val] === null)
585
+ continue;
586
+ // Любой вариант захода делаем массивом
587
+ let valuesArr = Array.isArray(values[val]) ? values[val] : [values[val]];
588
+ // Идём по каждому значению в массиве
589
+ for (let valueKey in valuesArr) {
590
+ let value = valuesArr[valueKey];
591
+ // Преобразуем
592
+ let isRef = false;
593
+ if (value instanceof Object
594
+ && value.hasOwnProperty('_value')
595
+ && (value._value instanceof Event ||
596
+ value._value instanceof HTMLInputElement ||
597
+ value._value instanceof ClipboardEvent ||
598
+ value._value instanceof DataTransfer ||
599
+ value._value instanceof FileList)) {
600
+ value = value.value;
601
+ isRef = true;
602
+ }
603
+ if (value instanceof Event && value.target instanceof HTMLInputElement && value.target.type === 'file')
604
+ value = value.target.files;
605
+ if (value instanceof HTMLInputElement && value.type === 'file')
606
+ value = value.files;
607
+ if (value instanceof ClipboardEvent)
608
+ value = value.clipboardData?.files;
609
+ if (value instanceof DataTransfer)
610
+ value = value.files;
611
+ if (value instanceof FileList) {
612
+ const newValues = [];
613
+ const files = Array.from(value);
614
+ for (let index = 0; index < files.length; index++) {
615
+ formData.append(val + "[]", files[index]);
616
+ newValues.push({
617
+ 'name': files[index].name,
618
+ 'data': asFormData ? null : await this.readFileAsync(files[index]),
619
+ 'number': index,
620
+ 'id': this.generateID(32)
621
+ });
622
+ }
623
+ if (Array.isArray(values[val])) {
624
+ values[val].splice(parseInt(valueKey), 1, ...newValues);
625
+ }
626
+ else {
627
+ values[val] = newValues;
628
+ }
629
+ }
630
+ }
631
+ }
632
+ formData.append("json", JSON.stringify(values));
633
+ return asFormData ? formData : values;
634
+ }
635
+ /**
636
+ * Прочесть файл асинхронно
637
+ * @param {File} file
638
+ * @param {'data' | 'text'} readAs
639
+ * @returns {Promise<string>}
640
+ */
641
+ readFileAsync(file, readAs = 'data') {
642
+ return new Promise((resolve, reject) => {
643
+ let reader = new FileReader();
644
+ reader.onloadend = () => { resolve(reader.result); };
645
+ reader.onerror = reject;
646
+ if (readAs === 'data')
647
+ reader.readAsDataURL(file);
648
+ if (readAs === 'text')
649
+ reader.readAsText(file);
650
+ });
651
+ }
652
+ /**
653
+ * Заполнить существующий объект пришедшими из БД данными
654
+ * сохраняя при этом оригинальные классы и функции
655
+ * @param {*} object
656
+ * @param {*} values
657
+ */
658
+ fillObject(object, values) {
659
+ for (let prop in object) {
660
+ if (!values.hasOwnProperty(prop))
661
+ continue;
662
+ if (typeof values[prop] === 'object' && typeof object[prop] === 'object' && values[prop] !== null && object[prop] !== null) {
663
+ this.fillObject(object[prop], values[prop]);
664
+ continue;
665
+ }
666
+ object[prop] = values[prop];
667
+ }
668
+ return object;
669
+ }
670
+ generateID(length) {
671
+ let result = '';
672
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
673
+ const charactersLength = characters.length;
674
+ let counter = 0;
675
+ while (counter < length) {
676
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
677
+ counter += 1;
678
+ }
679
+ return result;
680
+ }
681
+ /**
682
+ * Вычисляет на лету детерминированную строку, которая будет использоваться как часть ключа для подписи.
683
+ * Ключ генерируется на основе последовательности числовых преобразований, что делает его неочевидным
684
+ * при статическом анализе кода. Этот метод всегда возвращает один и тот же результат.
685
+ * @returns {string} Детерминированно сгенерированная строка.
686
+ */
687
+ getDeterministicKeyPart() {
688
+ // Начальная последовательность чисел. Она может быть любой, главное - постоянной.
689
+ const sequence = [5, 12, 23, 8, 17, 3, 29, 14, 31, 2, 11, 28, 7, 20, 1, 16, 22, 5, 13, 19, 4, 25, 10, 30, 9, 21, 6, 18, 27, 15, 24, 26];
690
+ // "Магическое число" для усложнения преобразований.
691
+ const magicNumber = 177;
692
+ let result = '';
693
+ for (let i = 0; i < sequence.length; i++) {
694
+ const num = sequence[i];
695
+ let charCode;
696
+ // В зависимости от позиции применяем разные преобразования,
697
+ // чтобы получить символы в разных регистрах и цифры.
698
+ switch (i % 3) {
699
+ case 0:
700
+ // Генерируем заглавную букву (A-Z)
701
+ charCode = (num * magicNumber + i) % 26;
702
+ result += String.fromCharCode(65 + charCode);
703
+ break;
704
+ case 1:
705
+ // Генерируем строчную букву (a-z)
706
+ charCode = (num + magicNumber * (i + 1)) % 26;
707
+ result += String.fromCharCode(97 + charCode);
708
+ break;
709
+ case 2:
710
+ // Генерируем цифру (0-9)
711
+ charCode = (num * (i + 1) + magicNumber) % 10;
712
+ result += String.fromCharCode(48 + charCode);
713
+ break;
714
+ }
715
+ }
716
+ return result;
717
+ }
718
+ /**
719
+ * Создаёт подпись для запроса на основе его параметров, используя лучшие практики.
720
+ * Собирает все GET и POST параметры, сортирует их и формирует каноническую строку для хеширования.
721
+ * @param url URL запроса, включая GET-параметры.
722
+ * @param params POST-параметры запроса (в виде объекта).
723
+ * @returns Promise<string> Строка подписи.
724
+ */
725
+ async _generateRequestSignature(url, params) {
726
+ const secretKey = (this.token ?? '') + this.getDeterministicKeyPart();
727
+ // Если параметры - это FormData или не объект, мы не можем их надежно обработать для подписи.
728
+ // В этом случае подписываем пустую строку, как и раньше.
729
+ if (params instanceof FormData || typeof params !== 'object' || params === null) {
730
+ return this.hmac_sha256('', secretKey);
731
+ }
732
+ const allParams = {};
733
+ // 1. Извлекаем GET-параметры из URL
734
+ if (url.includes('?')) {
735
+ const queryString = url.split('?')[1];
736
+ const urlParams = new URLSearchParams(queryString);
737
+ urlParams.forEach((value, key) => {
738
+ allParams[key] = value;
739
+ });
740
+ }
741
+ // 2. Добавляем POST-параметры из тела запроса
742
+ Object.assign(allParams, params);
743
+ // 3. Создаём каноническую строку
744
+ // Сортируем ключи для консистентности
745
+ const sortedKeys = Object.keys(allParams).sort();
746
+ // Формируем строку вида "key1=value1&key2=value2..."
747
+ const canonicalString = sortedKeys
748
+ .map(key => {
749
+ const value = allParams[key];
750
+ // Пропускаем null и undefined значения, они не участвуют в подписи
751
+ if (value === null || value === undefined) {
752
+ return '';
753
+ }
754
+ // Если значение - объект (включая массив), заменяем его на слово "object" для консистентности подписи.
755
+ const stringValue = (typeof value === 'object' && value !== null) ? 'object' : String(value);
756
+ return `${encodeURIComponent(key)}=${encodeURIComponent(stringValue)}`;
757
+ })
758
+ .filter(part => part !== '') // Убираем пустые части
759
+ .join('&');
760
+ return this.hmac_sha256(canonicalString, secretKey);
761
+ }
762
+ /**
763
+ *
764
+ * @param {string} data
765
+ * @param {string} key
766
+ * @returns
767
+ */
768
+ async hmac_sha256(message, secret_key) {
769
+ if (message === null || message === undefined)
770
+ message = "";
771
+ if (typeof message !== 'string')
772
+ message = JSON.stringify(message);
773
+ const enc = new TextEncoder();
774
+ const encodedKey = enc.encode(secret_key);
775
+ const encodedMessage = enc.encode(message);
776
+ const data = encodedMessage;
777
+ const key = encodedKey;
778
+ if (typeof window === 'undefined') {
779
+ // Динамически импортируем модуль 'crypto' для Node.js, чтобы избежать ошибок на фронтенде,
780
+ // где этот модуль недоступен.
781
+ // @ts-ignore
782
+ const { createHmac } = await import('crypto');
783
+ const hash = createHmac('sha256', key).update(data).digest('hex');
784
+ return Promise.resolve(hash);
785
+ }
786
+ else {
787
+ if (typeof window.crypto?.subtle === 'undefined')
788
+ return Promise.resolve("");
789
+ return window.crypto.subtle
790
+ .importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign', 'verify'])
791
+ .then(key => window.crypto.subtle.sign('HMAC', key, data))
792
+ .then(signature => {
793
+ const signatureArray = new Uint8Array(signature);
794
+ return Array.from(signatureArray)
795
+ .map(byte => byte.toString(16).padStart(2, '0'))
796
+ .join('');
797
+ });
798
+ }
799
+ }
800
+ }
801
+ /**
802
+ * Экземпляр-синглтон класса FLAMEREST.
803
+ * Используйте его для всех запросов в приложении.
804
+ * `import REST from './REST.ts';`
805
+ */
806
+ const REST = new FLAMEREST();
807
+ if (typeof window !== 'undefined') {
808
+ window.REST = REST;
809
+ }
810
+ export default REST;
package/REST.ts CHANGED
@@ -103,6 +103,12 @@ export class FLAMEREST {
103
103
  */
104
104
  public version: string;
105
105
 
106
+ /**
107
+ * Версия кода клиента (отправляется в заголовке каждого запроса).
108
+ * Можно установить извне: REST.VERSION_CODE = "1.2.3"
109
+ */
110
+ public VERSION_CODE: string = "1.0.0";
111
+
106
112
  /**
107
113
  * Если авторизация по токену, то он сюда подставляется
108
114
  */
@@ -171,9 +177,15 @@ export class FLAMEREST {
171
177
  */
172
178
  async request(url: string, params: object | string | FormData, type: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', responseType: 'json' | 'blob' = 'json', isNeedToken = true, customHeaders: Record<string, string> = {}): Promise<BaseResponse<any> & Partial<Paginated>> {
173
179
 
174
- // Подставляем сервер автоматом в запрос начинающийся с /
175
- if (url[0] === '/' && url[1] !== '/') {
176
- url = this.SERVER + url;
180
+ // Подставляем сервер автоматом если URL не содержит протокол
181
+ // Не трогаем URL с явным протоколом (http://, https://) или протокол-независимые (//)
182
+ if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('//')) {
183
+ // Убираем начальный "/" если он есть, т.к. SERVER уже без слэша в конце
184
+ if (url[0] === '/') {
185
+ url = url.substring(1);
186
+ }
187
+ // Добавляем SERVER с разделителем "/"
188
+ url = this.SERVER + '/' + url;
177
189
  }
178
190
 
179
191
  // Создаём подпись запроса до его модификации
@@ -225,6 +237,9 @@ export class FLAMEREST {
225
237
  headers['Content-type'] = 'application/json; charset=utf-8';
226
238
  }
227
239
 
240
+ // Версия кода клиента — уходит с каждым запросом
241
+ headers['version'] = this.VERSION_CODE;
242
+
228
243
  const requestBody: RequestInit = {
229
244
  method: type,
230
245
  mode: 'cors',
@@ -233,7 +248,7 @@ export class FLAMEREST {
233
248
 
234
249
  if (type !== 'GET') {
235
250
  // Принимает и formData тоже и чистую json строку
236
- requestBody.body = params;
251
+ requestBody.body = params as any;
237
252
  }
238
253
 
239
254
  // Добавляем подпись в заголовки
@@ -376,6 +391,8 @@ export class FLAMEREST {
376
391
  xhr.open(type, url, true);
377
392
  xhr.responseType = 'json';
378
393
  xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
394
+ // Версия кода клиента — уходит с каждым запросом (fallback без fetch)
395
+ xhr.setRequestHeader('version', this.VERSION_CODE);
379
396
  xhr.withCredentials = false;
380
397
  xhr.send(params as any);
381
398
 
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "flamerest",
3
- "version": "1.0.126",
3
+ "version": "1.0.128",
4
4
  "description": "",
5
- "main": "./REST.ts",
6
- "typings": "./REST.ts",
5
+ "main": "REST.js",
6
+ "types": "REST.d.ts",
7
+ "type": "module",
7
8
  "exports": {
8
9
  ".": {
9
- "import": "./REST.ts",
10
- "types": "./REST.ts"
10
+ "import": "./REST.js",
11
+ "types": "./REST.d.ts"
11
12
  }
12
13
  },
13
14
  "scripts": {
14
- "publishx": "git push && npm --no-git-tag-version version patch && npm publish"
15
+ "build": "tsc REST.ts --declaration --emitDeclarationOnly false --module ESNext --target ES2020",
16
+ "p": "npm run build && git push && npm --no-git-tag-version version patch && npm publish"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
package/tsconfig.json CHANGED
@@ -8,7 +8,6 @@
8
8
  ],
9
9
  "module": "ESNext",
10
10
  "types": [
11
- "node"
12
11
  ],
13
12
  "moduleResolution": "Bundler",
14
13
  "strict": true,