@hkdigital/lib-core 0.4.57 → 0.4.59

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.
@@ -0,0 +1,275 @@
1
+ # JWT Utilities
2
+
3
+ A comprehensive JSON Web Token (JWT) library for signing, verifying, and
4
+ decoding JWT tokens with proper error handling and type safety.
5
+
6
+ ## Features
7
+
8
+ - **Sign tokens** with customizable algorithms and expiration
9
+ - **Verify tokens** with signature validation and expiration checks
10
+ - **Decode payloads** without verification for inspection purposes
11
+ - **Generate secret keys** for HMAC algorithms
12
+ - **Error handling** with specific error types for different failure modes
13
+ - **Type safety** with JSDoc type annotations
14
+
15
+ ## Quick Start
16
+
17
+ ```javascript
18
+ import { sign, verify, decodePayload, expiresAtUTC } from '$lib/auth/jwt.js';
19
+
20
+ // Create a token
21
+ const payload = { userId: 123, username: 'john' };
22
+ const secret = 'your-secret-key';
23
+ const token = sign(payload, secret);
24
+
25
+ // Verify and decode
26
+ const decoded = verify(token, secret);
27
+ console.log(decoded.userId); // 123
28
+
29
+ // Decode without verification (for inspection)
30
+ const payload = decodePayload(token);
31
+ console.log(payload.exp); // expiration timestamp
32
+
33
+ // Get human-readable expiration
34
+ const expiresAt = expiresAtUTC(payload);
35
+ console.log(expiresAt); // "Sun, 01 Jan 2023 00:00:00 GMT"
36
+ ```
37
+
38
+ ## API Reference
39
+
40
+ ### sign(claims, secretOrPrivateKey, options?)
41
+
42
+ Creates a JSON Web Token with the specified claims.
43
+
44
+ **Parameters:**
45
+ - `claims` (object) - JWT payload/claims
46
+ - `secretOrPrivateKey` (string|Buffer) - Secret for HMAC or private key
47
+ - `options` (object, optional) - Signing options
48
+
49
+ **Options:**
50
+ - `algorithm` (string) - Algorithm to use (default: 'HS512')
51
+ - `expiresIn` (string|number) - Token expiration (default: '24h')
52
+ - `issuer` (string) - Token issuer
53
+ - `audience` (string) - Token audience
54
+ - `subject` (string) - Token subject
55
+
56
+ **Returns:** `string` - JWT token
57
+
58
+ **Example:**
59
+ ```javascript
60
+ const token = sign(
61
+ { userId: 123, role: 'admin' },
62
+ 'secret-key',
63
+ {
64
+ algorithm: 'HS256',
65
+ expiresIn: '1h',
66
+ issuer: 'my-app'
67
+ }
68
+ );
69
+ ```
70
+
71
+ ### verify(token, secretOrPrivateKey, options?)
72
+
73
+ Verifies and decodes a JWT token.
74
+
75
+ **Parameters:**
76
+ - `token` (string) - JWT token to verify
77
+ - `secretOrPrivateKey` (string|Buffer) - Secret for HMAC or public key
78
+ - `options` (object, optional) - Verification options
79
+
80
+ **Options:**
81
+ - `algorithms` (string[]) - Allowed algorithms (default: ['HS512'])
82
+ - `issuer` (string) - Expected issuer
83
+ - `audience` (string) - Expected audience
84
+ - `ignoreExpiration` (boolean) - Skip expiration validation
85
+
86
+ **Returns:** `object` - Decoded JWT payload
87
+
88
+ **Throws:**
89
+ - `TokenExpiredError` - Token has expired
90
+ - `InvalidSignatureError` - Invalid signature
91
+ - `JsonWebTokenError` - Other JWT validation errors
92
+ - `NotBeforeError` - Token not yet valid
93
+
94
+ **Example:**
95
+ ```javascript
96
+ try {
97
+ const decoded = verify(token, 'secret-key');
98
+ console.log('User ID:', decoded.userId);
99
+ } catch (error) {
100
+ if (error instanceof TokenExpiredError) {
101
+ console.log('Token expired at:', error.expiredAt);
102
+ }
103
+ }
104
+ ```
105
+
106
+ ### decodePayload(token)
107
+
108
+ Decodes JWT payload without verification. Useful for inspecting token claims
109
+ before verification or when verification is not required.
110
+
111
+ **Parameters:**
112
+ - `token` (string) - JWT token to decode
113
+
114
+ **Returns:** `object` - Decoded JWT payload
115
+
116
+ **Throws:**
117
+ - `Error` - Invalid token format or malformed payload
118
+
119
+ **Example:**
120
+ ```javascript
121
+ // Inspect token without verification
122
+ const payload = decodePayload(token);
123
+ console.log('Token expires:', payload.exp);
124
+ console.log('Issued at:', payload.iat);
125
+
126
+ // Check if token is expired before verification
127
+ if (payload.exp && payload.exp < Date.now() / 1000) {
128
+ console.log('Token is expired');
129
+ }
130
+ ```
131
+
132
+ ### expiresAtUTC(token)
133
+
134
+ Converts the `exp` claim of a decoded token to a human-readable UTC string.
135
+
136
+ **Parameters:**
137
+ - `token` (object) - Decoded JWT payload with optional `exp` claim
138
+
139
+ **Returns:** `string|null` - UTC string or null if no expiration
140
+
141
+ **Example:**
142
+ ```javascript
143
+ const payload = decodePayload(token);
144
+ const expiresAt = expiresAtUTC(payload);
145
+
146
+ if (expiresAt) {
147
+ console.log('Token expires at:', expiresAt);
148
+ // "Sun, 01 Jan 2023 00:00:00 GMT"
149
+ } else {
150
+ console.log('Token never expires');
151
+ }
152
+ ```
153
+
154
+ ## Error Types
155
+
156
+ The library provides specific error types for different failure modes:
157
+
158
+ ### TokenExpiredError
159
+ Thrown when a token has expired.
160
+ - `message` - Error description
161
+ - `expiredAt` - Date when token expired
162
+
163
+ ### InvalidSignatureError
164
+ Thrown when token signature is invalid.
165
+ - `message` - Error description
166
+
167
+ ### JsonWebTokenError
168
+ Thrown for general JWT validation errors.
169
+ - `message` - Error description
170
+
171
+ ### NotBeforeError
172
+ Thrown when token is not yet valid (nbf claim).
173
+ - `message` - Error description
174
+ - `date` - Date when token becomes valid
175
+
176
+ ## Security Best Practices
177
+
178
+ ### Secret Key Management
179
+ - Use cryptographically strong secrets (256+ bits for HMAC)
180
+ - Store secrets securely (environment variables, key management services)
181
+ - Rotate secrets regularly
182
+ - Never commit secrets to version control
183
+
184
+ ```javascript
185
+ import { generateSecretKeyForHmacBase58 } from '$lib/auth/jwt.js';
186
+
187
+ // Generate a secure secret
188
+ const secret = generateSecretKeyForHmacBase58();
189
+ ```
190
+
191
+ ### Token Validation
192
+ - Always verify tokens before trusting claims
193
+ - Use specific algorithm allowlists in verification
194
+ - Validate issuer, audience, and other claims as needed
195
+ - Handle expiration appropriately for your use case
196
+
197
+ ```javascript
198
+ const decoded = verify(token, secret, {
199
+ algorithms: ['HS256'], // Only allow specific algorithms
200
+ issuer: 'trusted-issuer',
201
+ audience: 'my-app'
202
+ });
203
+ ```
204
+
205
+ ### Token Storage
206
+ - Store tokens securely (httpOnly cookies, secure storage)
207
+ - Use short expiration times when possible
208
+ - Implement token refresh mechanisms
209
+ - Clear tokens on logout
210
+
211
+ ## Common Patterns
212
+
213
+ ### Token Inspection Middleware
214
+ ```javascript
215
+ function inspectToken(token) {
216
+ try {
217
+ const payload = decodePayload(token);
218
+ const expiresAt = expiresAtUTC(payload);
219
+
220
+ return {
221
+ isExpired: payload.exp && payload.exp < Date.now() / 1000,
222
+ expiresAt,
223
+ userId: payload.userId,
224
+ role: payload.role
225
+ };
226
+ } catch (error) {
227
+ return { isValid: false, error: error.message };
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### Token Refresh Check
233
+ ```javascript
234
+ function shouldRefreshToken(token) {
235
+ const payload = decodePayload(token);
236
+ if (!payload.exp) return false;
237
+
238
+ const now = Date.now() / 1000;
239
+ const timeUntilExpiry = payload.exp - now;
240
+ const refreshThreshold = 5 * 60; // 5 minutes
241
+
242
+ return timeUntilExpiry < refreshThreshold && timeUntilExpiry > 0;
243
+ }
244
+ ```
245
+
246
+ ### Safe Token Verification
247
+ ```javascript
248
+ function safeVerifyToken(token, secret) {
249
+ try {
250
+ return { success: true, payload: verify(token, secret) };
251
+ } catch (error) {
252
+ return {
253
+ success: false,
254
+ error: error.constructor.name,
255
+ message: error.message
256
+ };
257
+ }
258
+ }
259
+ ```
260
+
261
+ ## Testing
262
+
263
+ Run the JWT tests:
264
+
265
+ ```bash
266
+ pnpm test:file src/lib/auth/jwt/
267
+ ```
268
+
269
+ The test suite covers:
270
+ - Token signing with various options
271
+ - Token verification with different scenarios
272
+ - Error handling for expired/invalid tokens
273
+ - Payload decoding without verification
274
+ - UTC time conversion
275
+ - Edge cases and error conditions
@@ -42,3 +42,19 @@ export function verify(token: string, secretOrPrivateKey: import("./typedef.js")
42
42
  * @returns {Error} - The corresponding internal error
43
43
  */
44
44
  export function castJwtError(error: Error): Error;
45
+ /**
46
+ * Decodes the payload of a JSON Web Token without verification
47
+ *
48
+ * @param {string} token - A JSON Web Token
49
+ *
50
+ * @returns {object} claims - The decoded JWT payload
51
+ */
52
+ export function decodePayload(token: string): object;
53
+ /**
54
+ * Returns the "exp" (expiresAt) property of a token as UTC string
55
+ *
56
+ * @param {object} token - Decoded JWT payload/claims
57
+ *
58
+ * @returns {string|null} "expires at" as UTC string or null if no exp claim
59
+ */
60
+ export function expiresAtUTC(token: object): string | null;
@@ -144,3 +144,51 @@ export function castJwtError(error) {
144
144
  // Return original error if not a known JWT error
145
145
  return error;
146
146
  }
147
+
148
+ /**
149
+ * Decodes the payload of a JSON Web Token without verification
150
+ *
151
+ * @param {string} token - A JSON Web Token
152
+ *
153
+ * @returns {object} claims - The decoded JWT payload
154
+ */
155
+ export function decodePayload(token) {
156
+ expect.notEmptyString(token);
157
+
158
+ const firstDot = token.indexOf('.');
159
+
160
+ if (firstDot === -1) {
161
+ throw new Error(
162
+ 'Invalid token, missing [.] token as payload start indicator'
163
+ );
164
+ }
165
+
166
+ const lastDot = token.lastIndexOf('.');
167
+
168
+ if (lastDot === firstDot) {
169
+ throw new Error(
170
+ 'Invalid token, missing second [.] token as payload end indicator'
171
+ );
172
+ }
173
+
174
+ const payload = token.slice(firstDot + 1, lastDot);
175
+
176
+ return JSON.parse(atob(payload));
177
+ }
178
+
179
+ /**
180
+ * Returns the "exp" (expiresAt) property of a token as UTC string
181
+ *
182
+ * @param {object} token - Decoded JWT payload/claims
183
+ *
184
+ * @returns {string|null} "expires at" as UTC string or null if no exp claim
185
+ */
186
+ export function expiresAtUTC(token) {
187
+ expect.object(token);
188
+
189
+ if ('exp' in token) {
190
+ return new Date(1000 * token.exp).toUTCString();
191
+ }
192
+
193
+ return null;
194
+ }
package/dist/auth/jwt.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @description
5
5
  * This module provides a clean API for JWT operations including signing,
6
- * verifying tokens, and generating secret keys.
6
+ * verifying tokens, decoding payloads, and generating secret keys.
7
7
  */
8
8
 
9
9
  export * from './jwt/util.js';
@@ -1,3 +1,4 @@
1
+ export * from "./util/typedef.js";
1
2
  declare const _default: {};
2
3
  export default _default;
3
4
  export type IterableTreeOptions = {
@@ -6,4 +6,6 @@
6
6
  * @property {boolean} outputIntermediateNodes
7
7
  */
8
8
 
9
+ export * from './util/typedef.js';
10
+
9
11
  export default {};
@@ -0,0 +1,252 @@
1
+ # Flat Tree Utilities
2
+
3
+ Tree serialization and deserialization utilities that convert between
4
+ hierarchical tree structures and flat tree format (ft1).
5
+
6
+ ## Overview
7
+
8
+ The flat tree utilities provide bidirectional conversion between:
9
+ - **Hierarchical Trees**: Standard nested object structures with children
10
+ - **Flat Tree Format (ft1)**: Compact serialized format with separate
11
+ nodes and edges
12
+
13
+ ## Key Features
14
+
15
+ - **Format Versioning**: ft1 format with forward compatibility
16
+ - **Property Preservation**: Maintains original property names
17
+ (`children`, `items`, etc.)
18
+ - **Shared Reference Handling**: Uses WeakMap to track objects that
19
+ appear multiple times
20
+ - **ID Collision Avoidance**: No conflicts between user IDs and tree
21
+ structure IDs
22
+ - **Efficient Serialization**: Compact flat edge array format
23
+
24
+ ## API Reference
25
+
26
+ ### `buildTree(flatTree, options)`
27
+
28
+ Converts ft1 flat tree format to hierarchical tree structure.
29
+
30
+ **Parameters:**
31
+ - `flatTree` - FlatTree object in ft1 format
32
+ - `options` - Optional configuration object
33
+
34
+ **Returns:** Reconstructed hierarchical tree or null
35
+
36
+ **Example:**
37
+ ```javascript
38
+ import { buildTree } from '$lib/generic/data/util/flat-tree.js';
39
+
40
+ const flatTree = {
41
+ format: 'ft1',
42
+ properties: ['children'],
43
+ nodes: [
44
+ { id: 'root', name: 'Root' },
45
+ { id: 'child1', name: 'Child 1' }
46
+ ],
47
+ edges: [0, 0, 1] // root->children->child1
48
+ };
49
+
50
+ const hierarchical = buildTree(flatTree);
51
+ // Result: { id: 'root', name: 'Root', children: [{ id: 'child1', name: 'Child 1' }] }
52
+ ```
53
+
54
+ ### `flattenTree(hierarchicalTree, options)`
55
+
56
+ Converts hierarchical tree to ft1 flat tree format.
57
+
58
+ **Parameters:**
59
+ - `hierarchicalTree` - Nested object structure with children
60
+ - `options` - Optional configuration object
61
+ - `childrenKey` (default: 'children') - Primary children property name
62
+
63
+ **Returns:** FlatTree object in ft1 format
64
+
65
+ **Example:**
66
+ ```javascript
67
+ import { flattenTree } from '$lib/generic/data/util/flat-tree.js';
68
+
69
+ const hierarchical = {
70
+ id: 'root',
71
+ name: 'Root',
72
+ children: [
73
+ { id: 'child1', name: 'Child 1' }
74
+ ]
75
+ };
76
+
77
+ const flatTree = flattenTree(hierarchical);
78
+ // Result: { format: 'ft1', properties: ['children'], nodes: [...], edges: [...] }
79
+ ```
80
+
81
+ ## ft1 Format Specification
82
+
83
+ The ft1 format consists of four main components:
84
+
85
+ ### Format Structure
86
+
87
+ ```javascript
88
+ {
89
+ format: 'ft1', // Format version identifier
90
+ properties: string[], // Array of property names used in edges
91
+ nodes: object[], // Array of node objects (index 0 = root)
92
+ edges: number[] // Flat array: [from, prop, to, from, prop, to, ...]
93
+ }
94
+ ```
95
+
96
+ ### Properties Array
97
+
98
+ Maps property indices to names for edge decoding:
99
+ ```javascript
100
+ properties: ['children', 'items', 'metadata']
101
+ // index: 0 1 2
102
+ ```
103
+
104
+ ### Nodes Array
105
+
106
+ Contains node data with children properties removed:
107
+ ```javascript
108
+ nodes: [
109
+ { id: 'root', name: 'Root' }, // index 0 (always root)
110
+ { id: 'child1', name: 'Child 1' }, // index 1
111
+ { id: 'item1', name: 'Item 1' } // index 2
112
+ ]
113
+ ```
114
+
115
+ ### Edges Array
116
+
117
+ Flat array in groups of 3: `[from_index, property_index, to_index]`
118
+ ```javascript
119
+ edges: [
120
+ 0, 0, 1, // root(0) -children(0)-> child1(1)
121
+ 0, 1, 2 // root(0) -items(1)-> item1(2)
122
+ ]
123
+ ```
124
+
125
+ ## Shared Object References
126
+
127
+ Objects that appear multiple times in the tree are automatically
128
+ handled using WeakMap tracking:
129
+
130
+ ```javascript
131
+ const sharedNode = { id: 'shared', name: 'Shared' };
132
+
133
+ const hierarchical = {
134
+ id: 'root',
135
+ children: [
136
+ { id: 'left', children: [sharedNode] },
137
+ { id: 'right', children: [sharedNode] } // Same object reference
138
+ ]
139
+ };
140
+
141
+ const flattened = flattenTree(hierarchical);
142
+ const rebuilt = buildTree(flattened);
143
+
144
+ // Shared reference is preserved
145
+ console.log(rebuilt.children[0].children[0] === rebuilt.children[1].children[0]); // true
146
+ ```
147
+
148
+ ## Property Detection
149
+
150
+ The flattening process automatically detects child-containing properties:
151
+
152
+ ```javascript
153
+ const tree = {
154
+ id: 'root',
155
+ children: [{ id: 'child1' }], // Detected as child property
156
+ items: [{ id: 'item1' }], // Detected as child property
157
+ metadata: 'string value' // Not a child property (ignored)
158
+ };
159
+ ```
160
+
161
+ ## Roundtrip Conversion
162
+
163
+ The utilities maintain data integrity across roundtrip conversions:
164
+
165
+ ```javascript
166
+ const original = { /* complex tree */ };
167
+ const flattened = flattenTree(original);
168
+ const rebuilt = buildTree(flattened);
169
+ const reflattened = flattenTree(rebuilt);
170
+
171
+ // Data integrity preserved
172
+ console.log(JSON.stringify(flattened) === JSON.stringify(reflattened)); // true
173
+ ```
174
+
175
+ ## Error Handling
176
+
177
+ The utilities provide clear error messages for invalid data:
178
+
179
+ - **Unsupported format**: When format is not 'ft1'
180
+ - **Invalid edges**: When edges array length is not multiple of 3
181
+ - **Invalid indices**: When node or property indices are out of bounds
182
+ - **Missing data**: When required fields are missing
183
+
184
+ ## Performance Considerations
185
+
186
+ - **WeakMap Usage**: Efficient object reference tracking
187
+ - **Flat Edge Array**: Compact memory usage for large trees
188
+ - **Property Index Mapping**: Fast property name lookup
189
+ - **No Deep Cloning**: Shallow copies preserve performance
190
+
191
+ ## Use Cases
192
+
193
+ ### Configuration Trees
194
+ ```javascript
195
+ const config = {
196
+ server: {
197
+ children: [
198
+ { name: 'port', value: 3000 },
199
+ { name: 'host', value: 'localhost' }
200
+ ]
201
+ }
202
+ };
203
+ ```
204
+
205
+ ### File System Trees
206
+ ```javascript
207
+ const fileTree = {
208
+ path: '/',
209
+ children: [
210
+ { path: '/src', children: [{ path: '/src/index.js' }] },
211
+ { path: '/package.json' }
212
+ ]
213
+ };
214
+ ```
215
+
216
+ ### Menu Structures
217
+ ```javascript
218
+ const menu = {
219
+ title: 'Main',
220
+ items: [
221
+ { title: 'File', items: [{ title: 'New' }, { title: 'Open' }] },
222
+ { title: 'Edit', items: [{ title: 'Copy' }, { title: 'Paste' }] }
223
+ ]
224
+ };
225
+ ```
226
+
227
+ ## Migration Guide
228
+
229
+ ### From Legacy Formats
230
+
231
+ If migrating from older tree formats, ensure your data structure
232
+ follows these patterns:
233
+
234
+ 1. **Root Object**: Single root node with child properties
235
+ 2. **Array Children**: Child properties should be arrays of objects
236
+ 3. **Object Structure**: Each node should be a plain object
237
+ 4. **No Circular References**: Avoid circular references (use shared
238
+ objects instead)
239
+
240
+ ### Type Safety
241
+
242
+ Use JSDoc type annotations for better development experience:
243
+
244
+ ```javascript
245
+ /**
246
+ * @param {import('./typedef.js').FlatTree<MyNodeType>} flatTree
247
+ * @returns {MyNodeType|null}
248
+ */
249
+ function processTree(flatTree) {
250
+ return buildTree(flatTree);
251
+ }
252
+ ```
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build a hierarchical tree from ft1 flat tree format
3
+ *
4
+ * @template {object} T
5
+ * @param {import('./typedef.js').FlatTree<T>} flatTree - Flat tree data
6
+ *
7
+ * @returns {T|null} reconstructed hierarchical tree or null
8
+ */
9
+ export function buildTree<T extends object>(flatTree: import("./typedef.js").FlatTree<T>): T | null;
10
+ /**
11
+ * Flatten a hierarchical tree into ft1 flat tree format
12
+ *
13
+ * @template {object} T
14
+ * @param {T} hierarchicalTree - Hierarchical tree with nested children
15
+ *
16
+ * @returns {import('./typedef.js').FlatTree<T>} flat tree data
17
+ */
18
+ export function flattenTree<T extends object>(hierarchicalTree: T): import("./typedef.js").FlatTree<T>;
19
+ export const FORMAT_FT1: "ft1";
@@ -0,0 +1,338 @@
1
+ /* ------------------------------------------------------------------ Imports */
2
+
3
+ import * as expect from '../../../util/expect.js';
4
+
5
+ /* ------------------------------------------------------------------ Exports */
6
+
7
+ export const FORMAT_FT1 = 'ft1';
8
+
9
+ /**
10
+ * Build a hierarchical tree from ft1 flat tree format
11
+ *
12
+ * @template {object} T
13
+ * @param {import('./typedef.js').FlatTree<T>} flatTree - Flat tree data
14
+ *
15
+ * @returns {T|null} reconstructed hierarchical tree or null
16
+ */
17
+ export function buildTree(flatTree) {
18
+ expect.object(flatTree);
19
+
20
+ const { format, properties, nodes, edges } = flatTree;
21
+
22
+ if (format !== FORMAT_FT1) {
23
+ throw new Error(`Unsupported format: ${format}.`);
24
+ }
25
+
26
+ if (!nodes || !nodes.length) {
27
+ return null;
28
+ }
29
+
30
+ if (!edges || !edges.length) {
31
+ return { ...nodes[0] };
32
+ }
33
+
34
+ if (edges.length % 3 !== 0) {
35
+ throw new Error('Invalid edges array: length must be multiple of 3');
36
+ }
37
+
38
+ // Create copies of all nodes to avoid mutating original data
39
+ const nodesCopy = nodes.map((node) => ({ ...node }));
40
+
41
+ // Process edges in groups of 3: [from, prop, to]
42
+ for (let i = 0; i < edges.length; i += 3) {
43
+ const fromIndex = edges[i];
44
+ const propIndex = edges[i + 1];
45
+ const toIndex = edges[i + 2];
46
+
47
+ // Validate indices
48
+ if (fromIndex >= nodesCopy.length || toIndex >= nodesCopy.length) {
49
+ throw new Error(
50
+ `Invalid node index in edge [${fromIndex}, ${propIndex}, ${toIndex}]`
51
+ );
52
+ }
53
+
54
+ if (propIndex >= properties.length) {
55
+ throw new Error(`Invalid property index: ${propIndex}`);
56
+ }
57
+
58
+ const fromNode = nodesCopy[fromIndex];
59
+ const toNode = nodesCopy[toIndex];
60
+ const propertyName = properties[propIndex];
61
+
62
+ // Add child to parent's property
63
+ /** @type {any} */
64
+ const dynamicFromNode = fromNode;
65
+
66
+ if (!dynamicFromNode[propertyName]) {
67
+ dynamicFromNode[propertyName] = [toNode];
68
+ } else if (Array.isArray(dynamicFromNode[propertyName])) {
69
+ // Check if this node is already in the array (shared reference)
70
+ if (!dynamicFromNode[propertyName].includes(toNode)) {
71
+ dynamicFromNode[propertyName].push(toNode);
72
+ }
73
+ } else {
74
+ // Convert single child to array
75
+ dynamicFromNode[propertyName] = [dynamicFromNode[propertyName], toNode];
76
+ }
77
+ }
78
+
79
+ return nodesCopy[0];
80
+ }
81
+
82
+ /**
83
+ * Flatten a hierarchical tree into ft1 flat tree format
84
+ *
85
+ * @template {object} T
86
+ * @param {T} hierarchicalTree - Hierarchical tree with nested children
87
+ *
88
+ * @returns {import('./typedef.js').FlatTree<T>} flat tree data
89
+ */
90
+ export function flattenTree(hierarchicalTree) {
91
+ expect.object(hierarchicalTree);
92
+
93
+ /** @type {T[]} */
94
+ const nodes = [];
95
+
96
+ /** @type {number[]} */
97
+ const edgeFrom = [];
98
+
99
+ /** @type {string[]} */
100
+ const edgePropertyNames = [];
101
+
102
+ /** @type {number[]} */
103
+ const edgeTo = [];
104
+
105
+ /** @type {Set<string>} */
106
+ const propertySet = new Set();
107
+
108
+ /** @type {WeakMap<object, number>} */
109
+ const objectToIndex = new WeakMap();
110
+
111
+ // First pass: collect all child-containing properties
112
+ const childProperties = new Set();
113
+ findChildProperties(hierarchicalTree, childProperties);
114
+
115
+ // Add root node (always index 0)
116
+ const rootCopy = extractNodeData(hierarchicalTree, childProperties);
117
+ nodes.push( /** @type {T} */ (rootCopy) );
118
+ objectToIndex.set(hierarchicalTree, 0);
119
+
120
+ // Process children recursively
121
+ processChildrenFt1(
122
+ hierarchicalTree,
123
+ 0,
124
+ nodes,
125
+ edgeFrom,
126
+ edgePropertyNames,
127
+ edgeTo,
128
+ propertySet,
129
+ objectToIndex,
130
+ childProperties
131
+ );
132
+
133
+ // Convert property set to sorted array for consistent output
134
+ const properties = Array.from(propertySet).sort();
135
+
136
+ // Build final edges array with property indices
137
+ /** @type {number[]} */
138
+ const edges = [];
139
+ for (let i = 0; i < edgeFrom.length; i++) {
140
+ edges.push(
141
+ edgeFrom[i],
142
+ properties.indexOf(edgePropertyNames[i]),
143
+ edgeTo[i]
144
+ );
145
+ }
146
+
147
+ return /** @type {import('./typedef.js').FlatTree<T>} */ ({
148
+ format: FORMAT_FT1,
149
+ properties,
150
+ nodes,
151
+ edges
152
+ });
153
+ }
154
+
155
+ /* ---------------------------------------------------------- Private methods */
156
+
157
+ /**
158
+ * Extract node data excluding children properties
159
+ *
160
+ * @param {Record<string,any>} node - Source node
161
+ * @param {Set<string>} propertiesToRemove - Set of property names to remove
162
+ *
163
+ * @returns {Record<string,any>} node data without children properties
164
+ */
165
+ function extractNodeData(node, propertiesToRemove) {
166
+ /** @type {any} */
167
+ const nodeData = { ...node };
168
+
169
+ // Remove all child-containing properties
170
+ for (const key of propertiesToRemove) {
171
+ delete nodeData[key];
172
+ }
173
+
174
+ return nodeData;
175
+ }
176
+
177
+ /**
178
+ * Find all properties that contain child objects
179
+ *
180
+ * @param {Record<string,any>} node - Node to analyze
181
+ * @param {Set<string>} childProperties - Set to collect property names
182
+ */
183
+ function findChildProperties(node, childProperties) {
184
+ // Find array properties that contain objects (potential children)
185
+ for (const [key, value] of Object.entries(node)) {
186
+ if (Array.isArray(value) && value.length > 0) {
187
+ // Check if array contains objects (potential children)
188
+ const hasObjects = value.some((item) => item && typeof item === 'object');
189
+ if (hasObjects) {
190
+ childProperties.add(key);
191
+ }
192
+ }
193
+ }
194
+
195
+ // Recursively check child nodes
196
+ for (const key of [...childProperties]) {
197
+ // Copy set to avoid modification during iteration
198
+ const children = node[key];
199
+ if (Array.isArray(children)) {
200
+ for (const child of children) {
201
+ if (child && typeof child === 'object') {
202
+ findChildProperties(child, childProperties);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Recursively process children for ft1 format
211
+ *
212
+ * @param {Record<string,any>} parentNode - Parent node with children
213
+ * @param {number} parentIndex - Parent node index
214
+ * @param {object[]} nodes - Nodes array to populate
215
+ * @param {number[]} edgeFrom - From node indices
216
+ * @param {string[]} edgePropertyNames - Property names
217
+ * @param {number[]} edgeTo - To node indices
218
+ * @param {Set<string>} propertySet - Set of property names
219
+ * @param {WeakMap<object, number>} objectToIndex - Map of objects to indices
220
+ * @param {Set<string>} childProperties - Set of all child-containing properties
221
+ */
222
+ function processChildrenFt1(
223
+ parentNode,
224
+ parentIndex,
225
+ nodes,
226
+ edgeFrom,
227
+ edgePropertyNames,
228
+ edgeTo,
229
+ propertySet,
230
+ objectToIndex,
231
+ childProperties
232
+ ) {
233
+ // Process all child-containing properties
234
+ for (const property of childProperties) {
235
+ const children = parentNode[property];
236
+
237
+ if (!children) {
238
+ continue;
239
+ }
240
+
241
+ if (Array.isArray(children)) {
242
+ for (const child of children) {
243
+ if (child && typeof child === 'object') {
244
+ processChildFt1(
245
+ child,
246
+ parentIndex,
247
+ property,
248
+ nodes,
249
+ edgeFrom,
250
+ edgePropertyNames,
251
+ edgeTo,
252
+ propertySet,
253
+ objectToIndex,
254
+ childProperties
255
+ );
256
+ }
257
+ }
258
+ } else if (children && typeof children === 'object') {
259
+ processChildFt1(
260
+ children,
261
+ parentIndex,
262
+ property,
263
+ nodes,
264
+ edgeFrom,
265
+ edgePropertyNames,
266
+ edgeTo,
267
+ propertySet,
268
+ objectToIndex,
269
+ childProperties
270
+ );
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Process a single child node for ft1 format
277
+ *
278
+ * @param {Record<string,any>} child - Child node
279
+ * @param {number} parentIndex - Parent node index
280
+ * @param {string} property - Property name where this child belongs
281
+ * @param {Record<string,any>[]} nodes - Nodes array to populate
282
+ * @param {number[]} edgeFrom - From node indices
283
+ * @param {string[]} edgePropertyNames - Property names
284
+ * @param {number[]} edgeTo - To node indices
285
+ * @param {Set<string>} propertySet - Set of property names
286
+ * @param {WeakMap<object, number>} objectToIndex - Map of objects to indices
287
+ * @param {Set<string>} childProperties - Set of all child-containing properties
288
+ */
289
+ function processChildFt1(
290
+ child,
291
+ parentIndex,
292
+ property,
293
+ nodes,
294
+ edgeFrom,
295
+ edgePropertyNames,
296
+ edgeTo,
297
+ propertySet,
298
+ objectToIndex,
299
+ childProperties
300
+ ) {
301
+ if (!child || typeof child !== 'object') {
302
+ return;
303
+ }
304
+
305
+ // Track property name
306
+ propertySet.add(property);
307
+
308
+ // Check if we've seen this object before
309
+ let childIndex = objectToIndex.get(child);
310
+
311
+ if (childIndex === undefined) {
312
+ // First time seeing this object - add it to nodes
313
+ childIndex = nodes.length;
314
+ const childCopy = extractNodeData(child, childProperties);
315
+ nodes.push(childCopy);
316
+ objectToIndex.set(child, childIndex);
317
+ }
318
+
319
+ // Add edge using separate arrays
320
+ edgeFrom.push(parentIndex);
321
+ edgePropertyNames.push(property);
322
+ edgeTo.push(childIndex);
323
+
324
+ // If this is a new object, recursively process its children
325
+ if (objectToIndex.get(child) === childIndex) {
326
+ processChildrenFt1(
327
+ child,
328
+ childIndex,
329
+ nodes,
330
+ edgeFrom,
331
+ edgePropertyNames,
332
+ edgeTo,
333
+ propertySet,
334
+ objectToIndex,
335
+ childProperties
336
+ );
337
+ }
338
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Flat tree data structure in ft1 format
3
+ */
4
+ export type FlatTree<T extends object> = {
5
+ /**
6
+ * - Format version ('ft1')
7
+ */
8
+ format: string;
9
+ /**
10
+ * - Array of property names used in edges
11
+ */
12
+ properties: string[];
13
+ /**
14
+ * - Array of node objects (index 0 = root)
15
+ */
16
+ nodes: T[];
17
+ /**
18
+ * - Flat array: [from, prop, to, from, prop, to, ...]
19
+ */
20
+ edges: number[];
21
+ };
@@ -0,0 +1,14 @@
1
+ /* ------------------------------------------------------------------ Typedef */
2
+
3
+ /**
4
+ * Flat tree data structure in ft1 format
5
+ *
6
+ * @template {object} T
7
+ * @typedef {object} FlatTree
8
+ * @property {string} format - Format version ('ft1')
9
+ * @property {string[]} properties - Array of property names used in edges
10
+ * @property {T[]} nodes - Array of node objects (index 0 = root)
11
+ * @property {number[]} edges - Flat array: [from, prop, to, from, prop, to, ...]
12
+ */
13
+
14
+ export {};
@@ -1,2 +1,3 @@
1
1
  export { default as Selector } from "./data/classes/Selector.js";
2
2
  export { default as IterableTree } from "./data/classes/IterableTree.js";
3
+ export { buildTree, flattenTree } from "./data/util/flat-tree.js";
@@ -1,2 +1,3 @@
1
1
  export { default as Selector } from './data/classes/Selector.js';
2
2
  export { default as IterableTree } from './data/classes/IterableTree.js';
3
+ export { buildTree, flattenTree } from './data/util/flat-tree.js';
@@ -176,6 +176,9 @@ export default class SceneBase {
176
176
  let progressIntervalId = null;
177
177
 
178
178
  let isAborted = false;
179
+
180
+ /** @type {SceneLoadingProgress|null} */
181
+ let lastSentProgress = null;
179
182
 
180
183
  const abort = () => {
181
184
  if (isAborted) return;
@@ -199,7 +202,9 @@ export default class SceneBase {
199
202
  if (onProgress) {
200
203
  progressIntervalId = setInterval(() => {
201
204
  if (!isAborted) {
202
- onProgress(this.progress);
205
+ const currentProgress = this.progress;
206
+ lastSentProgress = currentProgress;
207
+ onProgress(currentProgress);
203
208
  }
204
209
  }, 50); // Poll every 50ms
205
210
  }
@@ -229,6 +234,15 @@ export default class SceneBase {
229
234
  }
230
235
 
231
236
  if (progressIntervalId) {
237
+ if (onProgress) {
238
+ const finalProgress = this.progress;
239
+ // Only send final update if progress has changed
240
+ if (!lastSentProgress ||
241
+ finalProgress.sourcesLoaded !== lastSentProgress.sourcesLoaded ||
242
+ finalProgress.totalBytesLoaded !== lastSentProgress.totalBytesLoaded) {
243
+ onProgress(finalProgress);
244
+ }
245
+ }
232
246
  clearInterval(progressIntervalId);
233
247
  progressIntervalId = null;
234
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"