docpouch-client 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,10 +1,16 @@
1
- import type { I_UserEntry, I_UserLogin, I_UserCreation, I_UserUpdate, I_UserDisplay, I_DocumentEntry, I_DataStructure, I_LoginResponse, I_DocumentQuery, I_StructureCreation, I_WsMessage } from "./types.js";
2
- import { Manager } from "socket.io-client";
3
- export default class Index {
1
+ import type { I_UserEntry, I_UserLogin, I_UserCreation, I_UserUpdate, I_UserDisplay, I_DocumentEntry, I_DataStructure, I_LoginResponse, I_DocumentQuery, I_StructureCreation, I_WsMessage, I_EventString, I_DocumentType } from "./types.js";
2
+ import { Socket } from "socket.io-client";
3
+ export default class dbPouchClient {
4
4
  baseUrl: string;
5
- private token;
6
- socketManager: Manager<import("@socket.io/component-emitter").DefaultEventsMap, import("@socket.io/component-emitter").DefaultEventsMap>;
7
- constructor(baseUrl: string, callback?: (event: string, data: I_WsMessage) => void);
5
+ private authToken;
6
+ socket: Socket;
7
+ callbackFunction: ((event: I_EventString, data: I_WsMessage) => void) | undefined;
8
+ realTimeSync: boolean;
9
+ private connectionInProgress;
10
+ constructor(host: string, port?: number, callback?: (event: I_EventString, data: I_WsMessage) => void);
11
+ private setupPermanentSocketListeners;
12
+ setRealTimeSync: (newRealTimeSync: boolean) => void;
13
+ private initWebSocket;
8
14
  private request;
9
15
  login(credentials: I_UserLogin): Promise<I_LoginResponse | null>;
10
16
  listUsers(): Promise<I_UserEntry[]>;
@@ -20,6 +26,10 @@ export default class Index {
20
26
  getStructures(): Promise<I_DataStructure[]>;
21
27
  updateStructure(structureID: string, structureData: I_DataStructure): Promise<void>;
22
28
  removeStructure(structureID: string): Promise<void>;
29
+ createType(type: I_DocumentType): Promise<I_DocumentType>;
30
+ removeType(typeID: string): Promise<void>;
31
+ getTypes(): Promise<I_DocumentType[]>;
23
32
  setToken(token: string | null): void;
24
- getToken(): string | null;
33
+ getVersion(): string;
34
+ debugSocketConnection(): void;
25
35
  }
package/dist/index.js CHANGED
@@ -1,32 +1,154 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const socket_io_client_1 = require("socket.io-client");
4
- class Index {
5
- constructor(baseUrl, callback) {
6
- this.token = null;
7
- this.baseUrl = baseUrl;
8
- this.socketManager = new socket_io_client_1.Manager(baseUrl, { autoConnect: false });
9
- if (callback) {
10
- this.socketManager.connect();
11
- this.socketManager.onAny((event, data) => callback(event, data));
1
+ import { io } from "socket.io-client";
2
+ import packetJson from '../package.json';
3
+ export default class dbPouchClient {
4
+ constructor(host, port = 80, callback) {
5
+ this.authToken = null;
6
+ this.realTimeSync = false;
7
+ this.connectionInProgress = false;
8
+ this.setRealTimeSync = (newRealTimeSync) => {
9
+ console.log(`Setting realtime sync to: ${newRealTimeSync}. Current setting: ${this.realTimeSync}`);
10
+ // Skip if the setting isn't changing
11
+ if (newRealTimeSync === this.realTimeSync) {
12
+ console.log("Realtime sync setting unchanged, skipping");
13
+ return;
14
+ }
15
+ this.realTimeSync = newRealTimeSync;
16
+ if (newRealTimeSync && this.authToken) {
17
+ console.log("Activating realtime updates");
18
+ // Ensure we're not in the middle of another connection attempt
19
+ if (this.connectionInProgress) {
20
+ console.log("Connection already in progress, waiting before initializing");
21
+ setTimeout(() => this.initWebSocket(), 500);
22
+ }
23
+ else {
24
+ this.initWebSocket();
25
+ }
26
+ }
27
+ else if (!newRealTimeSync) {
28
+ console.log("Deactivating realtime updates");
29
+ if (this.socket.connected) {
30
+ console.log("Disconnecting socket");
31
+ this.socket.disconnect();
32
+ }
33
+ }
34
+ };
35
+ this.baseUrl = host;
36
+ const socketUrl = host.includes('://') ? host : `http://${host}`;
37
+ // Don't append port twice if it's already in the URL
38
+ const socketUrlWithPort = socketUrl.includes(':') && !socketUrl.endsWith(':')
39
+ ? socketUrl
40
+ : `${socketUrl}:${port}`;
41
+ console.log(`Initializing Socket.IO with URL: ${socketUrlWithPort}, path: /socket.io`);
42
+ this.socket = io(`${socketUrlWithPort}`, {
43
+ autoConnect: false,
44
+ transports: ['websocket'], // Try websocket only first
45
+ reconnection: true,
46
+ reconnectionAttempts: 5,
47
+ reconnectionDelay: 1000,
48
+ forceNew: true, // Force a new connection
49
+ auth: {
50
+ token: null // Will be set later
51
+ },
52
+ path: '/socket.io'
53
+ });
54
+ this.callbackFunction = callback;
55
+ this.setupPermanentSocketListeners();
56
+ }
57
+ setupPermanentSocketListeners() {
58
+ // These are permanent listeners that won't be removed
59
+ this.socket.on('connect_error', (error) => {
60
+ console.error('Socket connection error:', error.message);
61
+ this.connectionInProgress = false;
62
+ });
63
+ this.socket.on('connect', () => {
64
+ console.log('Socket connected successfully with ID:', this.socket.id);
65
+ this.connectionInProgress = false;
66
+ });
67
+ this.socket.on('disconnect', (reason) => {
68
+ console.log('Socket disconnected. Reason:', reason);
69
+ this.connectionInProgress = false;
70
+ });
71
+ this.socket.on('error', (error) => {
72
+ console.error('Socket error:', error);
73
+ this.connectionInProgress = false;
74
+ });
75
+ }
76
+ initWebSocket() {
77
+ console.log("initWebSocket called. Auth token present:", !!this.authToken, "Connection in progress:", this.connectionInProgress, "Socket connected:", this.socket.connected);
78
+ if (!this.authToken) {
79
+ console.log("Skipping WebSocket initialization: No auth token");
80
+ return;
81
+ }
82
+ if (this.connectionInProgress) {
83
+ console.log("Connection already in progress, skipping initialization");
84
+ return;
85
+ }
86
+ if (this.socket.connected) {
87
+ console.log("Socket already connected with ID:", this.socket.id);
88
+ return;
89
+ }
90
+ this.connectionInProgress = true;
91
+ try {
92
+ console.log("Setting up WebSocket connection with token");
93
+ // Update the auth token
94
+ this.socket.auth = { token: this.authToken };
95
+ // Remove any dynamic event listeners that might have been added
96
+ this.socket.offAny();
97
+ // Set up event handler for application events
98
+ this.socket.onAny((event, data) => {
99
+ if (event === "heartbeatPing") {
100
+ console.log("Ping event received:", data);
101
+ this.socket.emit("heartbeatPong", Date.now());
102
+ }
103
+ else if (this.callbackFunction) {
104
+ this.callbackFunction(event, data);
105
+ }
106
+ });
107
+ // Connect to the server
108
+ console.log("Connecting socket with auth token");
109
+ this.socket.connect();
110
+ // Add a timeout to detect if connection is taking too long
111
+ setTimeout(() => {
112
+ if (this.connectionInProgress) {
113
+ console.warn("Socket connection attempt timed out after 5 seconds");
114
+ this.connectionInProgress = false;
115
+ // If we're still not connected after the timeout, try again with polling
116
+ if (!this.socket.connected) {
117
+ console.log("Retrying connection with polling transport");
118
+ this.socket.io.opts.transports = ['polling', 'websocket'];
119
+ this.socket.connect();
120
+ }
121
+ }
122
+ }, 5000);
123
+ }
124
+ catch (error) {
125
+ console.error('Error in initWebSocket:', error);
126
+ this.connectionInProgress = false;
12
127
  }
13
128
  }
14
129
  async request(endpoint, method, body, requiresAuth = true) {
15
130
  const headers = {
16
- 'Content-Type': 'application/json'
131
+ 'Content-Type': 'application/json',
17
132
  };
18
- if (requiresAuth && this.token) {
19
- headers['Authorization'] = `Bearer ${this.token}`;
20
- }
133
+ if (requiresAuth && this.authToken)
134
+ headers['Authorization'] = `Bearer ${this.authToken}`;
135
+ if (this.socket.id)
136
+ headers['X-Socket-ID'] = this.socket.id;
21
137
  const options = {
22
138
  method,
23
139
  headers,
24
140
  body: body ? JSON.stringify(body) : undefined
25
141
  };
26
- const response = await fetch(`${this.baseUrl}${endpoint}`, options);
142
+ // Ensure endpoint starts with /
143
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
144
+ // Ensure baseUrl doesn't end with /
145
+ const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
146
+ const url = `${normalizedBaseUrl}${normalizedEndpoint}`;
147
+ console.log(`Making ${method} request to: ${url}`);
148
+ const response = await fetch(url, options);
27
149
  if (!response.ok) {
28
150
  if (response.status === 401 || response.status === 403) {
29
- this.token = null;
151
+ this.authToken = null;
30
152
  }
31
153
  throw new Error(`API error: ${response.status} ${response.statusText}`);
32
154
  }
@@ -36,7 +158,11 @@ class Index {
36
158
  async login(credentials) {
37
159
  const response = await this.request('/users/login', 'POST', credentials, false);
38
160
  if (response.token) {
39
- this.token = response.token;
161
+ this.authToken = response.token;
162
+ // Reconnect websocket with new token if realtime sync is enabled
163
+ if (this.realTimeSync) {
164
+ this.initWebSocket();
165
+ }
40
166
  return { token: response.token, isAdmin: response.isAdmin };
41
167
  }
42
168
  return null;
@@ -61,7 +187,7 @@ class Index {
61
187
  return await this.request('/docs/list', 'GET');
62
188
  }
63
189
  async fetchDocument(queryObject) {
64
- return await this.request(`/docs/fetch/`, 'GET');
190
+ return await this.request(`/docs/fetch/`, 'POST', queryObject);
65
191
  }
66
192
  async updateDocument(documentID, documentData) {
67
193
  await this.request(`/docs/update/${documentID}`, 'PATCH', documentData);
@@ -82,12 +208,60 @@ class Index {
82
208
  async removeStructure(structureID) {
83
209
  await this.request(`/structures/remove/${structureID}`, 'DELETE');
84
210
  }
211
+ // Data Type Endpoints
212
+ async createType(type) {
213
+ return await this.request('/types/write', 'POST', type);
214
+ }
215
+ async removeType(typeID) {
216
+ return await this.request(`/types/remove/${typeID}`, 'DELETE');
217
+ }
218
+ async getTypes() {
219
+ return await this.request('/types/list', 'GET');
220
+ }
85
221
  setToken(token) {
86
- this.token = token;
222
+ console.log("Setting token to:", token ? "***token***" : "null");
223
+ const tokenChanged = this.authToken !== token;
224
+ this.authToken = token;
225
+ if (!tokenChanged) {
226
+ console.log("Token unchanged, no need to reconnect");
227
+ return;
228
+ }
229
+ // If we have a new token and realtime sync is enabled
230
+ if (token && this.realTimeSync) {
231
+ console.log("New token set, will initialize WebSocket");
232
+ // Ensure any existing connection is closed first
233
+ if (this.socket.connected) {
234
+ console.log("Disconnecting existing socket before reconnecting with new token");
235
+ this.socket.disconnect();
236
+ }
237
+ // Wait a moment for the disconnect to complete
238
+ setTimeout(() => {
239
+ console.log("Initializing WebSocket with new token");
240
+ this.initWebSocket();
241
+ }, 300);
242
+ }
243
+ // If token was cleared or realtime sync is disabled
244
+ else if (this.socket.connected) {
245
+ console.log("Token cleared or realtime sync disabled, disconnecting");
246
+ this.socket.disconnect();
247
+ }
87
248
  }
88
- getToken() {
89
- return this.token;
249
+ getVersion() {
250
+ return packetJson.version;
251
+ }
252
+ debugSocketConnection() {
253
+ console.log("Socket connection debug info:");
254
+ console.log("- Connected:", this.socket.connected);
255
+ console.log("- Socket ID:", this.socket.id);
256
+ console.log("- Auth token present:", !!this.authToken);
257
+ console.log("- Connection in progress:", this.connectionInProgress);
258
+ console.log("- Realtime sync enabled:", this.realTimeSync);
259
+ console.log("- Socket options:", this.socket.io.opts);
260
+ // Try to force reconnection
261
+ if (!this.socket.connected && this.authToken && this.realTimeSync) {
262
+ console.log("Attempting to force reconnection...");
263
+ this.socket.auth = { token: this.authToken };
264
+ this.socket.connect();
265
+ }
90
266
  }
91
267
  }
92
- exports.default = Index;
93
- module.exports = Index;
package/dist/types.d.ts CHANGED
@@ -9,17 +9,23 @@ export interface I_UserCreation {
9
9
  name: string;
10
10
  password: string;
11
11
  email?: string;
12
+ department: string;
13
+ group: string;
12
14
  isAdmin: boolean;
13
15
  }
14
16
  export interface I_UserUpdate {
15
17
  name?: string;
16
18
  password?: string;
17
19
  email?: string;
20
+ department?: string;
21
+ group?: string;
18
22
  isAdmin?: boolean;
19
23
  }
20
24
  export interface I_UserDisplay {
21
25
  _id: string;
22
26
  username: string;
27
+ department: string;
28
+ group: string;
23
29
  email?: string;
24
30
  }
25
31
  export interface I_LoginResponse {
@@ -78,6 +84,15 @@ export interface I_StructureUpdate {
78
84
  reference?: any;
79
85
  fields?: any[];
80
86
  }
87
+ export interface I_DocumentType {
88
+ _id?: string;
89
+ type: number;
90
+ subType: number;
91
+ name: string;
92
+ description?: string;
93
+ defaultStructureID?: string;
94
+ }
95
+ export type I_EventString = 'subscribe' | 'unsubscribe' | 'heartbeatPong' | "heartbeatPing" | "newDocument" | "newStructure" | "newUser" | "removedID" | "changedDocument" | "changedStructure" | "changedUser" | "confirmSubscription" | "confirmUnsubscription" | "removedUser" | "removedStructure" | "removedDocument";
81
96
  export interface I_WsMessage {
82
97
  newDocument?: I_DocumentEntry;
83
98
  newStructure?: I_StructureEntry;
@@ -88,6 +103,6 @@ export interface I_WsMessage {
88
103
  changedUser?: I_UserUpdate;
89
104
  confirmSubscription?: boolean;
90
105
  confirmUnsubscription?: boolean;
91
- ping?: number;
92
- pong?: number;
106
+ heartbeatPing?: number;
107
+ heartbeatPong?: number;
93
108
  }
package/dist/types.js CHANGED
@@ -1,3 +1,2 @@
1
- "use strict";
2
1
  // Common type definitions for both frontend and backend
3
- Object.defineProperty(exports, "__esModule", { value: true });
2
+ export {};
package/jest.config.js ADDED
@@ -0,0 +1,18 @@
1
+ module.exports = {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ moduleNameMapper: {
5
+ '^(\\.{1,2}/.*)\\.js$': '$1',
6
+ },
7
+ transform: {
8
+ '^.+\\.tsx?$': ['ts-jest', {
9
+ useESM: true,
10
+ }],
11
+ },
12
+ extensionsToTreatAsEsm: ['.ts'],
13
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
14
+ testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
15
+ collectCoverage: true,
16
+ coverageDirectory: 'coverage',
17
+ coverageReporters: ['text', 'lcov'],
18
+ };
package/package.json CHANGED
@@ -1,23 +1,34 @@
1
- {
2
- "name": "docpouch-client",
3
- "version": "0.8.2",
4
- "main": "dist/index.js",
5
- "types": "dist/index.d.ts",
6
- "scripts": {
7
- "build": "tsc"
8
- },
9
- "keywords": [
10
- "docPouch",
11
- "API"
12
- ],
13
- "author": "Jan Frecè",
14
- "license": "MIT",
15
- "description": "A small class to more easily access the docPouch API",
16
- "devDependencies": {
17
- "@types/node": "^22.15.23",
18
- "socket.io-client": "^4.8.1"
19
- },
20
- "dependencies": {
21
- "typescript": "^5.8.3"
22
- }
23
- }
1
+ {
2
+ "name": "docpouch-client",
3
+ "version": "0.8.4",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "require": "./dist/index.js"
10
+ }
11
+ },
12
+ "type": "module",
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "jest"
16
+ },
17
+ "keywords": [
18
+ "docPouch",
19
+ "API"
20
+ ],
21
+ "author": "Jan Frecè",
22
+ "license": "MIT",
23
+ "description": "A small class to more easily access the docPouch API",
24
+ "devDependencies": {
25
+ "@types/jest": "^29.5.14",
26
+ "@types/node": "^24.0.1",
27
+ "jest": "^30.0.0",
28
+ "ts-jest": "^29.4.0"
29
+ },
30
+ "dependencies": {
31
+ "socket.io-client": "^4.8.1",
32
+ "typescript": "^5.8.3"
33
+ }
34
+ }
@@ -0,0 +1,489 @@
1
+ import dbPouchClient from '../index.js';
2
+ import packetJson from '../../package.json';
3
+ import {
4
+ I_UserLogin,
5
+ I_UserCreation,
6
+ I_UserUpdate,
7
+ I_DocumentEntry,
8
+ I_DocumentQuery,
9
+ I_StructureCreation,
10
+ I_DataStructure,
11
+ } from '../types.js';
12
+
13
+ // Mock socket.io-client
14
+ jest.mock('socket.io-client', () => {
15
+ const mockSocket = {
16
+ connect: jest.fn(),
17
+ emit: jest.fn(),
18
+ onAny: jest.fn(),
19
+ };
20
+ return {
21
+ io: jest.fn(() => mockSocket),
22
+ Socket: jest.requireActual('socket.io-client').Socket,
23
+ };
24
+ });
25
+
26
+ // Mock fetch API
27
+ global.fetch = jest.fn();
28
+ const mockFetch = global.fetch as jest.Mock;
29
+
30
+ describe('Index Class', () => {
31
+ let index: dbPouchClient;
32
+ const baseUrl = 'http://example.com';
33
+
34
+ beforeEach(() => {
35
+ // Reset mocks before each test
36
+ jest.clearAllMocks();
37
+ mockFetch.mockClear();
38
+
39
+ // Create a new instance of Index for each test
40
+ index = new dbPouchClient(baseUrl);
41
+
42
+ // Mock successful fetch response
43
+ mockFetch.mockImplementation(() =>
44
+ Promise.resolve({
45
+ ok: true,
46
+ status: 200,
47
+ statusText: 'OK',
48
+ json: () => Promise.resolve({}),
49
+ })
50
+ );
51
+ });
52
+
53
+ // Test constructor
54
+ describe('constructor', () => {
55
+ it('should initialize with the provided baseUrl', () => {
56
+ expect(index.baseUrl).toBe(baseUrl);
57
+ });
58
+
59
+ it('should initialize socket with autoConnect: false by default', () => {
60
+ expect(require('socket.io-client').io).toHaveBeenCalledWith(baseUrl, { autoConnect: false });
61
+ });
62
+
63
+ it('should connect socket and set up event handler when callback is provided', () => {
64
+ const mockCallback = jest.fn();
65
+ const indexWithCallback = new dbPouchClient(baseUrl, mockCallback);
66
+
67
+ expect(indexWithCallback.socket.connect).toHaveBeenCalled();
68
+ expect(indexWithCallback.socket.onAny).toHaveBeenCalled();
69
+ });
70
+ });
71
+
72
+ // Test utility methods
73
+ describe('utility methods', () => {
74
+ it('should set and get token correctly', () => {
75
+ const token = 'test-token';
76
+ index.setToken(token);
77
+ expect(index.getToken()).toBe(token);
78
+ });
79
+
80
+ it('should return null when token is not set', () => {
81
+ expect(index.getToken()).toBeNull();
82
+ });
83
+
84
+ it('should return the correct version', () => {
85
+ // Mock package.json
86
+ jest.mock('../../package.json', () => ({ version: packetJson.version }), { virtual: true });
87
+
88
+ // We need to re-create the index instance to pick up the mocked package.json
89
+ const newIndex = new dbPouchClient(baseUrl);
90
+ expect(newIndex.getVersion()).toBe(packetJson.version);
91
+ });
92
+ });
93
+
94
+ // Test user administration endpoints
95
+ describe('user administration endpoints', () => {
96
+ it('should login successfully and store token', async () => {
97
+ const credentials: I_UserLogin = { name: 'testuser', password: 'password' };
98
+ const mockResponse = { token: 'test-token', isAdmin: true };
99
+
100
+ mockFetch.mockImplementationOnce(() =>
101
+ Promise.resolve({
102
+ ok: true,
103
+ status: 200,
104
+ statusText: 'OK',
105
+ json: () => Promise.resolve(mockResponse),
106
+ })
107
+ );
108
+
109
+ const result = await index.login(credentials);
110
+
111
+ expect(mockFetch).toHaveBeenCalledWith(
112
+ `${baseUrl}/users/login`,
113
+ expect.objectContaining({
114
+ method: 'POST',
115
+ body: JSON.stringify(credentials),
116
+ })
117
+ );
118
+ expect(result).toEqual(mockResponse);
119
+ expect(index.getToken()).toBe('test-token');
120
+ });
121
+
122
+ it('should return null when login fails', async () => {
123
+ const credentials: I_UserLogin = { name: 'testuser', password: 'wrong-password' };
124
+
125
+ mockFetch.mockImplementationOnce(() =>
126
+ Promise.resolve({
127
+ ok: true,
128
+ status: 200,
129
+ statusText: 'OK',
130
+ json: () => Promise.resolve({}),
131
+ })
132
+ );
133
+
134
+ const result = await index.login(credentials);
135
+
136
+ expect(result).toBeNull();
137
+ });
138
+
139
+ it('should list users', async () => {
140
+ const mockUsers = [{ _id: '1', name: 'user1', password: 'pass1', department: 'dept1', group: 'group1', isAdmin: false }];
141
+
142
+ mockFetch.mockImplementationOnce(() =>
143
+ Promise.resolve({
144
+ ok: true,
145
+ status: 200,
146
+ statusText: 'OK',
147
+ json: () => Promise.resolve(mockUsers),
148
+ })
149
+ );
150
+
151
+ const result = await index.listUsers();
152
+
153
+ expect(mockFetch).toHaveBeenCalledWith(
154
+ `${baseUrl}/users/list`,
155
+ expect.objectContaining({
156
+ method: 'GET',
157
+ })
158
+ );
159
+ expect(result).toEqual(mockUsers);
160
+ });
161
+
162
+ it('should update a user', async () => {
163
+ const userId = '1';
164
+ const userData: I_UserUpdate = { name: 'updated-user', department: 'new-dept' };
165
+
166
+ await index.updateUser(userId, userData);
167
+
168
+ expect(mockFetch).toHaveBeenCalledWith(
169
+ `${baseUrl}/users/update/${userId}`,
170
+ expect.objectContaining({
171
+ method: 'PATCH',
172
+ body: JSON.stringify(userData),
173
+ })
174
+ );
175
+ });
176
+
177
+ it('should create a user', async () => {
178
+ const userData: I_UserCreation = {
179
+ name: 'newuser',
180
+ password: 'password',
181
+ department: 'dept',
182
+ group: 'group',
183
+ isAdmin: false
184
+ };
185
+ const mockResponse = { _id: '2', username: 'newuser', department: 'dept', group: 'group' };
186
+
187
+ mockFetch.mockImplementationOnce(() =>
188
+ Promise.resolve({
189
+ ok: true,
190
+ status: 200,
191
+ statusText: 'OK',
192
+ json: () => Promise.resolve(mockResponse),
193
+ })
194
+ );
195
+
196
+ const result = await index.createUser(userData);
197
+
198
+ expect(mockFetch).toHaveBeenCalledWith(
199
+ `${baseUrl}/users/create`,
200
+ expect.objectContaining({
201
+ method: 'POST',
202
+ body: JSON.stringify(userData),
203
+ })
204
+ );
205
+ expect(result).toEqual(mockResponse);
206
+ });
207
+
208
+ it('should remove a user', async () => {
209
+ const userId = '1';
210
+
211
+ await index.removeUser(userId);
212
+
213
+ expect(mockFetch).toHaveBeenCalledWith(
214
+ `${baseUrl}/users/remove/${userId}`,
215
+ expect.objectContaining({
216
+ method: 'DELETE',
217
+ })
218
+ );
219
+ });
220
+ });
221
+
222
+ // Test document management endpoints
223
+ describe('document management endpoints', () => {
224
+ it('should create a document', async () => {
225
+ const document: I_DocumentEntry = {
226
+ _id: '1',
227
+ title: 'Test Document',
228
+ type: 1,
229
+ subType: 2,
230
+ content: { data: 'test content' },
231
+ owner: 'user1'
232
+ };
233
+
234
+ mockFetch.mockImplementationOnce(() =>
235
+ Promise.resolve({
236
+ ok: true,
237
+ status: 200,
238
+ statusText: 'OK',
239
+ json: () => Promise.resolve(document),
240
+ })
241
+ );
242
+
243
+ const result = await index.createDocument(document);
244
+
245
+ expect(mockFetch).toHaveBeenCalledWith(
246
+ `${baseUrl}/docs/create`,
247
+ expect.objectContaining({
248
+ method: 'POST',
249
+ body: JSON.stringify(document),
250
+ })
251
+ );
252
+ expect(result).toEqual(document);
253
+ });
254
+
255
+ it('should list documents', async () => {
256
+ const mockDocuments = [
257
+ { _id: '1', title: 'Doc1', type: 1, subType: 1, content: {}, owner: 'user1' },
258
+ { _id: '2', title: 'Doc2', type: 2, subType: 2, content: {}, owner: 'user2' }
259
+ ];
260
+
261
+ mockFetch.mockImplementationOnce(() =>
262
+ Promise.resolve({
263
+ ok: true,
264
+ status: 200,
265
+ statusText: 'OK',
266
+ json: () => Promise.resolve(mockDocuments),
267
+ })
268
+ );
269
+
270
+ const result = await index.listDocuments();
271
+
272
+ expect(mockFetch).toHaveBeenCalledWith(
273
+ `${baseUrl}/docs/list`,
274
+ expect.objectContaining({
275
+ method: 'GET',
276
+ })
277
+ );
278
+ expect(result).toEqual(mockDocuments);
279
+ });
280
+
281
+ it('should fetch documents based on query', async () => {
282
+ const query: I_DocumentQuery = { type: 1, owner: 'user1' };
283
+ const mockDocuments = [
284
+ { _id: '1', title: 'Doc1', type: 1, subType: 1, content: {}, owner: 'user1' }
285
+ ];
286
+
287
+ mockFetch.mockImplementationOnce(() =>
288
+ Promise.resolve({
289
+ ok: true,
290
+ status: 200,
291
+ statusText: 'OK',
292
+ json: () => Promise.resolve(mockDocuments),
293
+ })
294
+ );
295
+
296
+ const result = await index.fetchDocument(query);
297
+
298
+ expect(mockFetch).toHaveBeenCalledWith(
299
+ `${baseUrl}/docs/fetch/`,
300
+ expect.objectContaining({
301
+ method: 'POST',
302
+ body: JSON.stringify(query),
303
+ })
304
+ );
305
+ expect(result).toEqual(mockDocuments);
306
+ });
307
+
308
+ it('should update a document', async () => {
309
+ const documentId = '1';
310
+ const documentData: I_DocumentEntry = {
311
+ _id: '1',
312
+ title: 'Updated Document',
313
+ type: 1,
314
+ subType: 2,
315
+ content: { data: 'updated content' },
316
+ owner: 'user1'
317
+ };
318
+
319
+ await index.updateDocument(documentId, documentData);
320
+
321
+ expect(mockFetch).toHaveBeenCalledWith(
322
+ `${baseUrl}/docs/update/${documentId}`,
323
+ expect.objectContaining({
324
+ method: 'PATCH',
325
+ body: JSON.stringify(documentData),
326
+ })
327
+ );
328
+ });
329
+
330
+ it('should remove a document', async () => {
331
+ const documentId = '1';
332
+
333
+ await index.removeDocument(documentId);
334
+
335
+ expect(mockFetch).toHaveBeenCalledWith(
336
+ `${baseUrl}/docs/remove/${documentId}`,
337
+ expect.objectContaining({
338
+ method: 'DELETE',
339
+ })
340
+ );
341
+ });
342
+ });
343
+
344
+ // Test data structure endpoints
345
+ describe('data structure endpoints', () => {
346
+ it('should create a structure', async () => {
347
+ const structure: I_StructureCreation = {
348
+ name: 'Test Structure',
349
+ description: 'Test Description',
350
+ fields: [{ name: 'field1', type: 'string' }]
351
+ };
352
+ const mockResponse: I_DataStructure = {
353
+ _id: '1',
354
+ name: 'Test Structure',
355
+ description: 'Test Description',
356
+ fields: [{ name: 'field1', type: 'string' }]
357
+ };
358
+
359
+ mockFetch.mockImplementationOnce(() =>
360
+ Promise.resolve({
361
+ ok: true,
362
+ status: 200,
363
+ statusText: 'OK',
364
+ json: () => Promise.resolve(mockResponse),
365
+ })
366
+ );
367
+
368
+ const result = await index.createStructure(structure);
369
+
370
+ expect(mockFetch).toHaveBeenCalledWith(
371
+ `${baseUrl}/structures/create`,
372
+ expect.objectContaining({
373
+ method: 'POST',
374
+ body: JSON.stringify(structure),
375
+ })
376
+ );
377
+ expect(result).toEqual(mockResponse);
378
+ });
379
+
380
+ it('should get structures', async () => {
381
+ const mockStructures = [
382
+ { _id: '1', name: 'Structure1', description: 'Desc1', fields: [] },
383
+ { _id: '2', name: 'Structure2', description: 'Desc2', fields: [] }
384
+ ];
385
+
386
+ mockFetch.mockImplementationOnce(() =>
387
+ Promise.resolve({
388
+ ok: true,
389
+ status: 200,
390
+ statusText: 'OK',
391
+ json: () => Promise.resolve(mockStructures),
392
+ })
393
+ );
394
+
395
+ const result = await index.getStructures();
396
+
397
+ expect(mockFetch).toHaveBeenCalledWith(
398
+ `${baseUrl}/structures/list`,
399
+ expect.objectContaining({
400
+ method: 'GET',
401
+ })
402
+ );
403
+ expect(result).toEqual(mockStructures);
404
+ });
405
+
406
+ it('should update a structure', async () => {
407
+ const structureId = '1';
408
+ const structureData: I_DataStructure = {
409
+ name: 'Updated Structure',
410
+ description: 'Updated Description',
411
+ fields: [{ name: 'updatedField', type: 'number' }]
412
+ };
413
+
414
+ await index.updateStructure(structureId, structureData);
415
+
416
+ expect(mockFetch).toHaveBeenCalledWith(
417
+ `${baseUrl}/structures/update/${structureId}`,
418
+ expect.objectContaining({
419
+ method: 'PATCH',
420
+ body: JSON.stringify(structureData),
421
+ })
422
+ );
423
+ });
424
+
425
+ it('should remove a structure', async () => {
426
+ const structureId = '1';
427
+
428
+ await index.removeStructure(structureId);
429
+
430
+ expect(mockFetch).toHaveBeenCalledWith(
431
+ `${baseUrl}/structures/remove/${structureId}`,
432
+ expect.objectContaining({
433
+ method: 'DELETE',
434
+ })
435
+ );
436
+ });
437
+ });
438
+
439
+ // Test error handling
440
+ describe('error handling', () => {
441
+ it('should throw an error when API request fails', async () => {
442
+ mockFetch.mockImplementationOnce(() =>
443
+ Promise.resolve({
444
+ ok: false,
445
+ status: 500,
446
+ statusText: 'Internal Server Error',
447
+ })
448
+ );
449
+
450
+ await expect(index.listUsers()).rejects.toThrow('API error: 500 Internal Server Error');
451
+ });
452
+
453
+ it('should clear token when receiving 401 or 403 status', async () => {
454
+ // Set a token first
455
+ index.setToken('test-token');
456
+
457
+ // Mock 401 response
458
+ mockFetch.mockImplementationOnce(() =>
459
+ Promise.resolve({
460
+ ok: false,
461
+ status: 401,
462
+ statusText: 'Unauthorized',
463
+ })
464
+ );
465
+
466
+ await expect(index.listUsers()).rejects.toThrow('API error: 401 Unauthorized');
467
+ expect(index.getToken()).toBeNull();
468
+ });
469
+ });
470
+
471
+ // Test WebSocket functionality
472
+ describe('WebSocket functionality', () => {
473
+ it('should connect socket when callback is provided', () => {
474
+ const mockCallback = jest.fn();
475
+ const indexWithCallback = new dbPouchClient(baseUrl, mockCallback);
476
+
477
+ // Verify that connect was called
478
+ expect(indexWithCallback.socket.connect).toHaveBeenCalled();
479
+ });
480
+
481
+ it('should set up event handler when callback is provided', () => {
482
+ const mockCallback = jest.fn();
483
+ const indexWithCallback = new dbPouchClient(baseUrl, mockCallback);
484
+
485
+ // Verify that onAny was called
486
+ expect(indexWithCallback.socket.onAny).toHaveBeenCalled();
487
+ });
488
+ });
489
+ });
package/src/index.ts CHANGED
@@ -6,42 +6,179 @@ import type {
6
6
  I_UserDisplay,
7
7
  I_DocumentEntry,
8
8
  I_DataStructure,
9
- I_LoginResponse, I_DocumentQuery, I_StructureCreation, I_WsMessage, I_EventString
9
+ I_LoginResponse, I_DocumentQuery, I_StructureCreation, I_WsMessage, I_EventString, I_DocumentType
10
10
  } from "./types.js";
11
11
  import {io, Socket} from "socket.io-client";
12
12
  import packetJson from '../package.json'
13
13
 
14
- export default class Index {
14
+ export default class dbPouchClient {
15
15
  baseUrl: string;
16
- private token: string | null = null;
17
- socket: Socket
16
+ private authToken: string | null = null;
17
+ socket: Socket;
18
+ callbackFunction;
19
+ realTimeSync: boolean = false;
20
+ private connectionInProgress = false;
18
21
 
19
- constructor(baseUrl: string, callback?: (event: I_EventString, data: I_WsMessage) => void) {
20
- this.baseUrl = baseUrl;
21
22
 
22
- this.socket = io(baseUrl, { autoConnect: false });
23
- if (callback) {
24
- this.socket.connect();
23
+ constructor(host: string, port: number = 80, callback?: (event: I_EventString, data: I_WsMessage) => void) {
24
+ this.baseUrl = host;
25
+
26
+ const socketUrl = host.includes('://') ? host : `http://${host}`;
27
+
28
+ // Don't append port twice if it's already in the URL
29
+ const socketUrlWithPort = socketUrl.includes(':') && !socketUrl.endsWith(':')
30
+ ? socketUrl
31
+ : `${socketUrl}:${port}`;
32
+
33
+
34
+ console.log(`Initializing Socket.IO with URL: ${socketUrlWithPort}, path: /socket.io`);
35
+
36
+ this.socket = io(`${socketUrlWithPort}`, {
37
+ autoConnect: false,
38
+ transports: ['websocket'], // Try websocket only first
39
+ reconnection: true,
40
+ reconnectionAttempts: 5,
41
+ reconnectionDelay: 1000,
42
+ forceNew: true, // Force a new connection
43
+ auth: {
44
+ token: null // Will be set later
45
+ },
46
+ path: '/socket.io'
47
+ });
48
+
49
+ this.callbackFunction = callback;
50
+
51
+ this.setupPermanentSocketListeners();
52
+ }
53
+
54
+ private setupPermanentSocketListeners() {
55
+ // These are permanent listeners that won't be removed
56
+ this.socket.on('connect_error', (error) => {
57
+ console.error('Socket connection error:', error.message);
58
+ this.connectionInProgress = false;
59
+ });
60
+
61
+ this.socket.on('connect', () => {
62
+ console.log('Socket connected successfully with ID:', this.socket.id);
63
+ this.connectionInProgress = false;
64
+ });
65
+
66
+ this.socket.on('disconnect', (reason) => {
67
+ console.log('Socket disconnected. Reason:', reason);
68
+ this.connectionInProgress = false;
69
+ });
70
+
71
+ this.socket.on('error', (error) => {
72
+ console.error('Socket error:', error);
73
+ this.connectionInProgress = false;
74
+ });
75
+ }
76
+
77
+ setRealTimeSync = (newRealTimeSync: boolean) => {
78
+ console.log(`Setting realtime sync to: ${newRealTimeSync}. Current setting: ${this.realTimeSync}`);
79
+
80
+ // Skip if the setting isn't changing
81
+ if (newRealTimeSync === this.realTimeSync) {
82
+ console.log("Realtime sync setting unchanged, skipping");
83
+ return;
84
+ }
85
+
86
+ this.realTimeSync = newRealTimeSync;
87
+
88
+ if (newRealTimeSync && this.authToken) {
89
+ console.log("Activating realtime updates");
90
+
91
+ // Ensure we're not in the middle of another connection attempt
92
+ if (this.connectionInProgress) {
93
+ console.log("Connection already in progress, waiting before initializing");
94
+ setTimeout(() => this.initWebSocket(), 500);
95
+ } else {
96
+ this.initWebSocket();
97
+ }
98
+ } else if (!newRealTimeSync) {
99
+ console.log("Deactivating realtime updates");
100
+ if (this.socket.connected) {
101
+ console.log("Disconnecting socket");
102
+ this.socket.disconnect();
103
+ }
104
+ }
105
+ }
106
+
107
+ private initWebSocket() {
108
+ console.log("initWebSocket called. Auth token present:", !!this.authToken,
109
+ "Connection in progress:", this.connectionInProgress,
110
+ "Socket connected:", this.socket.connected);
111
+
112
+ if (!this.authToken) {
113
+ console.log("Skipping WebSocket initialization: No auth token");
114
+ return;
115
+ }
116
+
117
+ if (this.connectionInProgress) {
118
+ console.log("Connection already in progress, skipping initialization");
119
+ return;
120
+ }
121
+
122
+ if (this.socket.connected) {
123
+ console.log("Socket already connected with ID:", this.socket.id);
124
+ return;
125
+ }
126
+
127
+ this.connectionInProgress = true;
128
+
129
+ try {
130
+ console.log("Setting up WebSocket connection with token");
131
+
132
+ // Update the auth token
133
+ this.socket.auth = { token: this.authToken };
134
+
135
+ // Remove any dynamic event listeners that might have been added
136
+ this.socket.offAny();
137
+
138
+ // Set up event handler for application events
25
139
  this.socket.onAny((event: I_EventString, data: I_WsMessage) => {
26
140
  if (event === "heartbeatPing") {
27
141
  console.log("Ping event received:", data);
28
142
  this.socket.emit("heartbeatPong", Date.now());
29
143
  }
30
- else {
31
- callback(event, data);
144
+ else if (this.callbackFunction){
145
+ this.callbackFunction(event, data);
32
146
  }
33
147
  });
148
+
149
+ // Connect to the server
150
+ console.log("Connecting socket with auth token");
151
+ this.socket.connect();
152
+
153
+ // Add a timeout to detect if connection is taking too long
154
+ setTimeout(() => {
155
+ if (this.connectionInProgress) {
156
+ console.warn("Socket connection attempt timed out after 5 seconds");
157
+ this.connectionInProgress = false;
158
+
159
+ // If we're still not connected after the timeout, try again with polling
160
+ if (!this.socket.connected) {
161
+ console.log("Retrying connection with polling transport");
162
+ this.socket.io.opts.transports = ['polling', 'websocket'];
163
+ this.socket.connect();
164
+ }
165
+ }
166
+ }, 5000);
167
+ } catch (error) {
168
+ console.error('Error in initWebSocket:', error);
169
+ this.connectionInProgress = false;
34
170
  }
35
171
  }
36
172
 
37
173
  private async request<T>(endpoint: string, method: string, body?: any, requiresAuth: boolean = true): Promise<T> {
38
174
  const headers: HeadersInit = {
39
- 'Content-Type': 'application/json'
175
+ 'Content-Type': 'application/json',
40
176
  };
41
177
 
42
- if (requiresAuth && this.token) {
43
- headers['Authorization'] = `Bearer ${this.token}`;
44
- }
178
+ if (requiresAuth && this.authToken)
179
+ headers['Authorization'] = `Bearer ${this.authToken}`;
180
+ if (this.socket.id)
181
+ headers['X-Socket-ID'] = this.socket.id;
45
182
 
46
183
  const options: RequestInit = {
47
184
  method,
@@ -49,11 +186,19 @@ export default class Index {
49
186
  body: body ? JSON.stringify(body) : undefined
50
187
  };
51
188
 
52
- const response = await fetch(`${this.baseUrl}${endpoint}`, options);
189
+ // Ensure endpoint starts with /
190
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
191
+ // Ensure baseUrl doesn't end with /
192
+ const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
193
+
194
+ const url = `${normalizedBaseUrl}${normalizedEndpoint}`;
195
+ console.log(`Making ${method} request to: ${url}`);
196
+
197
+ const response = await fetch(url, options);
53
198
 
54
199
  if (!response.ok) {
55
200
  if (response.status === 401 || response.status === 403) {
56
- this.token = null;
201
+ this.authToken = null;
57
202
  }
58
203
  throw new Error(`API error: ${response.status} ${response.statusText}`);
59
204
  }
@@ -65,7 +210,13 @@ export default class Index {
65
210
  async login(credentials: I_UserLogin): Promise<I_LoginResponse | null> {
66
211
  const response = await this.request<I_LoginResponse>('/users/login', 'POST', credentials, false);
67
212
  if (response.token) {
68
- this.token = response.token;
213
+ this.authToken = response.token;
214
+
215
+ // Reconnect websocket with new token if realtime sync is enabled
216
+ if (this.realTimeSync) {
217
+ this.initWebSocket();
218
+ }
219
+
69
220
  return {token: response.token, isAdmin: response.isAdmin};
70
221
  }
71
222
  return null;
@@ -125,17 +276,72 @@ export default class Index {
125
276
  await this.request<void>(`/structures/remove/${structureID}`, 'DELETE');
126
277
  }
127
278
 
279
+ // Data Type Endpoints
280
+ async createType(type: I_DocumentType): Promise<I_DocumentType> {
281
+ return await this.request<I_DocumentType>('/types/write', 'POST', type);
282
+ }
283
+
284
+ async removeType(typeID: string) {
285
+ return await this.request<void>(`/types/remove/${typeID}`, 'DELETE');
286
+ }
287
+
288
+ async getTypes(): Promise<I_DocumentType[]> {
289
+ return await this.request<I_DocumentType[]>('/types/list', 'GET');
290
+ }
291
+
128
292
  setToken(token: string | null): void {
129
- this.token = token;
293
+ console.log("Setting token to:", token ? "***token***" : "null");
294
+
295
+ const tokenChanged = this.authToken !== token;
296
+ this.authToken = token;
297
+
298
+ if (!tokenChanged) {
299
+ console.log("Token unchanged, no need to reconnect");
300
+ return;
301
+ }
302
+
303
+ // If we have a new token and realtime sync is enabled
304
+ if (token && this.realTimeSync) {
305
+ console.log("New token set, will initialize WebSocket");
306
+
307
+ // Ensure any existing connection is closed first
308
+ if (this.socket.connected) {
309
+ console.log("Disconnecting existing socket before reconnecting with new token");
310
+ this.socket.disconnect();
311
+ }
312
+
313
+ // Wait a moment for the disconnect to complete
314
+ setTimeout(() => {
315
+ console.log("Initializing WebSocket with new token");
316
+ this.initWebSocket();
317
+ }, 300);
318
+ }
319
+ // If token was cleared or realtime sync is disabled
320
+ else if (this.socket.connected) {
321
+ console.log("Token cleared or realtime sync disabled, disconnecting");
322
+ this.socket.disconnect();
323
+ }
130
324
  }
131
325
 
132
- getToken() {
326
+ getVersion() {
133
327
  return packetJson.version;
134
328
  }
135
329
 
136
- getVersion() {
137
- return "0.0.1";
330
+ debugSocketConnection(): void {
331
+ console.log("Socket connection debug info:");
332
+ console.log("- Connected:", this.socket.connected);
333
+ console.log("- Socket ID:", this.socket.id);
334
+ console.log("- Auth token present:", !!this.authToken);
335
+ console.log("- Connection in progress:", this.connectionInProgress);
336
+ console.log("- Realtime sync enabled:", this.realTimeSync);
337
+ console.log("- Socket options:", this.socket.io.opts);
338
+
339
+ // Try to force reconnection
340
+ if (!this.socket.connected && this.authToken && this.realTimeSync) {
341
+ console.log("Attempting to force reconnection...");
342
+ this.socket.auth = { token: this.authToken };
343
+ this.socket.connect();
344
+ }
138
345
  }
139
- }
140
346
 
141
- module.exports = Index;
347
+ }
package/src/types.ts CHANGED
@@ -104,10 +104,20 @@ export interface I_StructureUpdate {
104
104
  fields?: any[];
105
105
  }
106
106
 
107
+ // Document type related types
108
+ export interface I_DocumentType {
109
+ _id?: string;
110
+ type: number;
111
+ subType: number;
112
+ name: string;
113
+ description?: string;
114
+ defaultStructureID?: string;
115
+ }
116
+
107
117
  // WebSocket-related types
108
118
  export type I_EventString = 'subscribe' | 'unsubscribe' | 'heartbeatPong' | "heartbeatPing" | "newDocument" | "newStructure" |
109
119
  "newUser" | "removedID" | "changedDocument" | "changedStructure" | "changedUser" | "confirmSubscription" |
110
- "confirmUnsubscription";
120
+ "confirmUnsubscription" | "removedUser" | "removedStructure" | "removedDocument";
111
121
 
112
122
  export interface I_WsMessage {
113
123
  newDocument?: I_DocumentEntry;
package/tsconfig.json CHANGED
@@ -12,8 +12,10 @@
12
12
  "rootDir": "./src",
13
13
  "declaration": true,
14
14
  "emitDeclarationOnly": false,
15
- "resolveJsonModule": true
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "allowSyntheticDefaultImports": true
16
18
  },
17
19
  "include": ["**/*.ts", "**/*.tsx"],
18
20
  "exclude": ["node_modules", "**/*.test.ts", "**/*.types.ts", "./dist/**/*"]
19
- }
21
+ }