@reforgium/internal 2.0.5 → 2.0.7

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.
@@ -6,38 +6,83 @@ class LocalStorage {
6
6
  this.prefix = prefix;
7
7
  }
8
8
  get length() {
9
- return Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix())).length;
9
+ const storage = this.storage;
10
+ if (!storage) {
11
+ return 0;
12
+ }
13
+ try {
14
+ return Object.keys(storage).filter((key) => key.startsWith(this.safePrefix())).length;
15
+ }
16
+ catch {
17
+ return 0;
18
+ }
10
19
  }
11
20
  get(key) {
12
- const storageKey = this.getSafePrefix(key);
13
- const raw = localStorage.getItem(storageKey);
14
- if (raw == null) {
21
+ const storage = this.storage;
22
+ if (!storage) {
15
23
  return null;
16
24
  }
25
+ const storageKey = this.getSafePrefix(key);
17
26
  try {
18
- const parsed = JSON.parse(raw);
19
- return parsed ?? null;
27
+ const raw = storage.getItem(storageKey);
28
+ if (raw == null) {
29
+ return null;
30
+ }
31
+ return JSON.parse(raw);
20
32
  }
21
33
  catch {
22
- localStorage.removeItem(storageKey);
34
+ try {
35
+ storage.removeItem(storageKey);
36
+ }
37
+ catch {
38
+ /* noop */
39
+ }
23
40
  return null;
24
41
  }
25
42
  }
26
43
  set(key, value) {
27
- localStorage.setItem(this.getSafePrefix(key), JSON.stringify(value));
44
+ try {
45
+ this.storage?.setItem(this.getSafePrefix(key), JSON.stringify(value));
46
+ }
47
+ catch {
48
+ /* Storage is optional and may reject writes (SSR, quota, privacy mode). */
49
+ }
28
50
  }
29
51
  remove(key) {
30
- localStorage.removeItem(this.getSafePrefix(key));
52
+ try {
53
+ this.storage?.removeItem(this.getSafePrefix(key));
54
+ }
55
+ catch {
56
+ /* noop */
57
+ }
31
58
  }
32
59
  clear() {
33
- const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix()));
34
- keys.forEach((key) => localStorage.removeItem(key));
60
+ const storage = this.storage;
61
+ if (!storage) {
62
+ return;
63
+ }
64
+ try {
65
+ Object.keys(storage)
66
+ .filter((key) => key.startsWith(this.safePrefix()))
67
+ .forEach((key) => storage.removeItem(key));
68
+ }
69
+ catch {
70
+ /* noop */
71
+ }
72
+ }
73
+ get storage() {
74
+ try {
75
+ return typeof localStorage === 'undefined' ? null : localStorage;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
35
80
  }
36
81
  getSafePrefix(key) {
37
- return this.prefix ? `${this.prefix}:${key}` : String(key);
82
+ return this.prefix ? this.prefix + ':' + String(key) : String(key);
38
83
  }
39
84
  safePrefix() {
40
- return this.prefix ? `${this.prefix}:` : '';
85
+ return this.prefix ? this.prefix + ':' : '';
41
86
  }
42
87
  }
43
88
 
@@ -55,6 +100,13 @@ class LruCache {
55
100
  }
56
101
  set limit(value) {
57
102
  this._limit = Math.max(1, Math.floor(value || 0));
103
+ while (this.map.size > this._limit) {
104
+ const oldest = this.map.keys().next().value;
105
+ if (oldest === undefined) {
106
+ break;
107
+ }
108
+ this.map.delete(oldest);
109
+ }
58
110
  }
59
111
  get(key) {
60
112
  if (!this.map.has(key)) {
@@ -129,38 +181,83 @@ class SessionStorage {
129
181
  this.prefix = prefix;
130
182
  }
131
183
  get length() {
132
- return Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix())).length;
184
+ const storage = this.storage;
185
+ if (!storage) {
186
+ return 0;
187
+ }
188
+ try {
189
+ return Object.keys(storage).filter((key) => key.startsWith(this.safePrefix())).length;
190
+ }
191
+ catch {
192
+ return 0;
193
+ }
133
194
  }
134
195
  get(key) {
135
- const storageKey = this.getSafePrefix(key);
136
- const raw = sessionStorage.getItem(storageKey);
137
- if (raw == null) {
196
+ const storage = this.storage;
197
+ if (!storage) {
138
198
  return null;
139
199
  }
200
+ const storageKey = this.getSafePrefix(key);
140
201
  try {
141
- const parsed = JSON.parse(raw);
142
- return parsed ?? null;
202
+ const raw = storage.getItem(storageKey);
203
+ if (raw == null) {
204
+ return null;
205
+ }
206
+ return JSON.parse(raw);
143
207
  }
144
208
  catch {
145
- sessionStorage.removeItem(storageKey);
209
+ try {
210
+ storage.removeItem(storageKey);
211
+ }
212
+ catch {
213
+ /* noop */
214
+ }
146
215
  return null;
147
216
  }
148
217
  }
149
218
  set(key, value) {
150
- sessionStorage.setItem(this.getSafePrefix(key), JSON.stringify(value));
219
+ try {
220
+ this.storage?.setItem(this.getSafePrefix(key), JSON.stringify(value));
221
+ }
222
+ catch {
223
+ /* Storage is optional and may reject writes (SSR, quota, privacy mode). */
224
+ }
151
225
  }
152
226
  remove(key) {
153
- sessionStorage.removeItem(this.getSafePrefix(key));
227
+ try {
228
+ this.storage?.removeItem(this.getSafePrefix(key));
229
+ }
230
+ catch {
231
+ /* noop */
232
+ }
154
233
  }
155
234
  clear() {
156
- const keys = Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix()));
157
- keys.forEach((key) => sessionStorage.removeItem(key));
235
+ const storage = this.storage;
236
+ if (!storage) {
237
+ return;
238
+ }
239
+ try {
240
+ Object.keys(storage)
241
+ .filter((key) => key.startsWith(this.safePrefix()))
242
+ .forEach((key) => storage.removeItem(key));
243
+ }
244
+ catch {
245
+ /* noop */
246
+ }
247
+ }
248
+ get storage() {
249
+ try {
250
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
251
+ }
252
+ catch {
253
+ return null;
254
+ }
158
255
  }
159
256
  getSafePrefix(key) {
160
- return this.prefix ? `${this.prefix}:${key}` : String(key);
257
+ return this.prefix ? this.prefix + ':' + String(key) : String(key);
161
258
  }
162
259
  safePrefix() {
163
- return this.prefix ? `${this.prefix}:` : '';
260
+ return this.prefix ? this.prefix + ':' : '';
164
261
  }
165
262
  }
166
263
 
@@ -412,16 +509,28 @@ const debounceSignal = (src, ms = 150, opts) => {
412
509
  */
413
510
  const throttleSignal = (src, ms = 100, opts) => {
414
511
  const out = signal(src(), ...(ngDevMode ? [{ debugName: "out" }] : /* istanbul ignore next */ []));
415
- let last = 0;
416
- let queued;
512
+ let lastEmissionAt = null;
513
+ let queuedValue;
514
+ let hasQueuedValue = false;
417
515
  let timeoutRef;
516
+ const clearPendingTimer = () => {
517
+ if (timeoutRef) {
518
+ clearTimeout(timeoutRef);
519
+ timeoutRef = null;
520
+ }
521
+ };
522
+ const emitNow = (value) => {
523
+ out.set(value);
524
+ lastEmissionAt = performance.now();
525
+ hasQueuedValue = false;
526
+ clearPendingTimer();
527
+ };
418
528
  const flush = () => {
419
- if (queued !== undefined) {
420
- out.set(queued);
421
- queued = undefined;
422
- last = performance.now();
529
+ timeoutRef = null;
530
+ if (!hasQueuedValue) {
531
+ return;
423
532
  }
424
- timeoutRef = 0;
533
+ emitNow(queuedValue);
425
534
  };
426
535
  effect((onCleanup) => {
427
536
  const v = src();
@@ -430,24 +539,19 @@ const throttleSignal = (src, ms = 100, opts) => {
430
539
  }
431
540
  untracked(() => {
432
541
  const now = performance.now();
433
- if (now - last >= ms) {
434
- out.set(v);
435
- last = now;
436
- clearTimeout(timeoutRef);
437
- timeoutRef = null;
438
- queued = undefined;
542
+ if (lastEmissionAt == null || now - lastEmissionAt >= ms) {
543
+ emitNow(v);
544
+ return;
439
545
  }
440
- else {
441
- queued = v;
442
- timeoutRef = setTimeout(flush, ms - (now - last));
546
+ queuedValue = v;
547
+ hasQueuedValue = true;
548
+ if (!timeoutRef) {
549
+ timeoutRef = setTimeout(flush, ms - (now - lastEmissionAt));
443
550
  }
444
551
  });
445
552
  onCleanup(() => {
446
- if (timeoutRef) {
447
- clearTimeout(timeoutRef);
448
- timeoutRef = null;
449
- }
450
- queued = undefined;
553
+ clearPendingTimer();
554
+ hasQueuedValue = false;
451
555
  });
452
556
  }, { injector: opts?.injector });
453
557
  return out.asReadonly();
@@ -534,7 +638,12 @@ const parseToDate = (value, format = 'dd.MM.yyyy') => {
534
638
  };
535
639
  tokens.forEach((token, i) => map[token]?.(i));
536
640
  const date = new Date(year, month, day, hours, minutes, seconds);
537
- if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
641
+ if ((tokens.includes('yyyy') && date.getFullYear() !== year) ||
642
+ (tokens.includes('MM') && date.getMonth() !== month) ||
643
+ (tokens.includes('dd') && date.getDate() !== day) ||
644
+ (tokens.includes('HH') && date.getHours() !== hours) ||
645
+ (tokens.includes('mm') && date.getMinutes() !== minutes) ||
646
+ (tokens.includes('ss') && date.getSeconds() !== seconds)) {
538
647
  return null;
539
648
  }
540
649
  return date;
@@ -606,7 +715,7 @@ const toDate = (v) => {
606
715
  * Converts a date string from a given format to ISO (UTC).
607
716
  * Example:
608
717
  * reformatDateToISO("24.11.2025", "dd.MM.yyyy")
609
- * "2025-11-24T00:00:00.000Z"
718
+ * > "2025-11-24T00:00:00.000Z"
610
719
  */
611
720
  function reformatDateToISO(dateStr, mask) {
612
721
  if (!dateStr || !mask) {
@@ -851,20 +960,25 @@ function getChainedValue(obj, path) {
851
960
  }
852
961
 
853
962
  const encodeSpacesAsPercent20 = (query) => query.replace(/\+/g, '%20');
963
+ const URL_PARTS_RE = /^([a-zA-Z][a-zA-Z\d+.-]*:)?(\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?$/;
854
964
  /**
855
965
  * Normalizes a given URL by removing redundant slashes, resolving dot segments,
856
- * and ensuring there are no trailing slashes at the end of the URL.
966
+ * and ensuring there are no trailing slashes at the end of the pathname.
967
+ *
968
+ * Query strings and hashes are preserved byte-for-byte.
857
969
  *
858
970
  * @param {string} url - The URL string to normalize.
859
971
  * @return {string} - The normalized URL string.
860
972
  */
861
973
  function normalizeUrl(url) {
862
- const [protocol, rest] = url.split('://');
863
- const cleaned = (rest ?? protocol)
864
- .replace(/\/{2,}/g, '/')
865
- .replace(/\/\.\//g, '/')
866
- .replace(/\/$/, '');
867
- return rest ? `${protocol}://${cleaned}` : cleaned;
974
+ const match = url.match(URL_PARTS_RE);
975
+ if (!match) {
976
+ return url;
977
+ }
978
+ const [, protocol = '', authority = '', pathname = '', query = '', hash = ''] = match;
979
+ const normalizedPath = normalizePathname(pathname);
980
+ const withoutRootSlash = authority && normalizedPath === '/' ? '' : normalizedPath;
981
+ return `${protocol}${authority}${withoutRootSlash}${query}${hash}`;
868
982
  }
869
983
  /**
870
984
  * Replaces placeholders in the provided URL template with corresponding values from the params object.
@@ -873,17 +987,59 @@ function normalizeUrl(url) {
873
987
  * @param {string} template URL template containing placeholders to replace.
874
988
  * @param {AnyDict} [params] Optional dictionary containing key-value pairs.
875
989
  * Keys correspond to placeholders in the template, and values will replace them.
990
+ * @param {FillUrlWithParamsOptions} [options] Placeholder materialization options.
876
991
  * @return {string} URL with placeholders replaced by corresponding values from the params object.
877
992
  * Defaults to the original template if params is not provided.
878
993
  */
879
- function fillUrlWithParams(template, params) {
994
+ function fillUrlWithParams(template, params, options = {}) {
880
995
  if (!params) {
881
996
  return template;
882
997
  }
883
998
  return template.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
884
- return encodeURIComponent(params[key] ?? '').replaceAll('%2F', '/');
999
+ const value = params[key];
1000
+ if (value == null) {
1001
+ switch (options.missingParam ?? 'throw') {
1002
+ case 'keep':
1003
+ return `:${key}`;
1004
+ case 'empty':
1005
+ return '';
1006
+ case 'throw':
1007
+ default:
1008
+ throw new Error(`Missing URL parameter "${key}"`);
1009
+ }
1010
+ }
1011
+ const encoded = encodeURIComponent(String(value));
1012
+ return options.preserveSlash ? encoded.replaceAll('%2F', '/') : encoded;
885
1013
  });
886
1014
  }
1015
+ const normalizePathname = (pathname) => {
1016
+ if (!pathname) {
1017
+ return '';
1018
+ }
1019
+ const hasLeadingSlash = pathname.startsWith('/');
1020
+ const segments = pathname.split('/');
1021
+ const normalized = [];
1022
+ for (const segment of segments) {
1023
+ if (!segment || segment === '.') {
1024
+ continue;
1025
+ }
1026
+ if (segment === '..') {
1027
+ if (normalized.length && normalized[normalized.length - 1] !== '..') {
1028
+ normalized.pop();
1029
+ }
1030
+ else if (!hasLeadingSlash) {
1031
+ normalized.push(segment);
1032
+ }
1033
+ continue;
1034
+ }
1035
+ normalized.push(segment);
1036
+ }
1037
+ const nextPath = `${hasLeadingSlash ? '/' : ''}${normalized.join('/')}`;
1038
+ if (nextPath === '/') {
1039
+ return nextPath;
1040
+ }
1041
+ return nextPath.replace(/\/$/, '');
1042
+ };
887
1043
  /**
888
1044
  * Low-level URL helper for appending query parameters to an existing URL.
889
1045
  *
@@ -1003,18 +1159,17 @@ const makeQuery = (object, concatType, resolveConcatType) => {
1003
1159
  * @param {string} pureRoute - Template route path to match, which may include placeholders
1004
1160
  * (e.g., `:id`).
1005
1161
  * @param {boolean} [withSubroute=false] - Determines whether to allow subroute matching. If true,
1006
- * actualRoute must start with the filled pureRoute.
1162
+ * actualRoute must match the template exactly or continue on a segment boundary.
1007
1163
  * @return {boolean} Returns true if the actual route matches the template
1008
1164
  * route (and optional subroute condition if enabled), otherwise false.
1009
1165
  */
1010
1166
  const compareRoutes = (actualRoute, pureRoute, withSubroute = false) => {
1011
- if (!pureRoute.includes(':')) {
1012
- return withSubroute ? actualRoute.startsWith(pureRoute) : pureRoute === actualRoute;
1013
- }
1014
- else {
1015
- const filled = fillRouteTemplate(actualRoute, pureRoute);
1016
- return withSubroute ? actualRoute.startsWith(filled) : filled === actualRoute;
1167
+ const actualSegments = splitRouteSegments(actualRoute);
1168
+ const templateSegments = splitRouteSegments(pureRoute);
1169
+ if ((!withSubroute && actualSegments.length !== templateSegments.length) || actualSegments.length < templateSegments.length) {
1170
+ return false;
1017
1171
  }
1172
+ return templateSegments.every((segment, index) => segment.startsWith(':') || segment === actualSegments[index]);
1018
1173
  };
1019
1174
  /**
1020
1175
  * Processes and materializes a route path by decoding and, if necessary, replacing route parameters.
@@ -1025,25 +1180,30 @@ const compareRoutes = (actualRoute, pureRoute, withSubroute = false) => {
1025
1180
  * @returns {string} Fully materialized and decoded route path.
1026
1181
  */
1027
1182
  const materializeRoutePath = (actualRoute, pureRoute) => {
1028
- if (!pureRoute.includes(':')) {
1029
- return decodeURIComponent(pureRoute);
1030
- }
1031
- else {
1032
- return decodeURIComponent(fillRouteTemplate(actualRoute, pureRoute));
1033
- }
1183
+ const materialized = pureRoute.includes(':') ? fillRouteTemplate(actualRoute, pureRoute) : pureRoute;
1184
+ return safeDecodeRoute(materialized);
1034
1185
  };
1186
+ const splitRouteSegments = (route) => route.split('/').filter(Boolean);
1035
1187
  const fillRouteTemplate = (actualRoute, pureRoute) => {
1036
- const actualSegments = actualRoute.split('/').filter(Boolean);
1037
- const templateSegments = pureRoute.split('/').filter(Boolean);
1188
+ const actualSegments = splitRouteSegments(actualRoute);
1189
+ const templateSegments = splitRouteSegments(pureRoute);
1038
1190
  const prefix = actualRoute.startsWith('/') ? '/' : '';
1039
1191
  const segments = [];
1040
1192
  for (let i = 0; i < templateSegments.length; i++) {
1041
1193
  const tempSegment = templateSegments[i];
1042
1194
  const actSegment = actualSegments[i];
1043
- segments.push(tempSegment.startsWith(':') ? actSegment : tempSegment);
1195
+ segments.push(tempSegment.startsWith(':') ? (actSegment ?? tempSegment) : tempSegment);
1044
1196
  }
1045
1197
  return prefix + segments.join('/');
1046
1198
  };
1199
+ const safeDecodeRoute = (route) => {
1200
+ try {
1201
+ return decodeURIComponent(route);
1202
+ }
1203
+ catch {
1204
+ return route;
1205
+ }
1206
+ };
1047
1207
 
1048
1208
  /**
1049
1209
  * Recommended high-level entrypoint for query-string work in `internal`.
@@ -1210,61 +1370,92 @@ function sortInputToTokens(sort) {
1210
1370
  return normalizeSortInput(sort).map((item) => sortRuleToToken(item));
1211
1371
  }
1212
1372
 
1373
+ const getComparableKind = (value) => {
1374
+ if (Array.isArray(value))
1375
+ return 'array';
1376
+ if (value instanceof Date)
1377
+ return 'date';
1378
+ if (value instanceof Map)
1379
+ return 'map';
1380
+ if (value instanceof Set)
1381
+ return 'set';
1382
+ const prototype = Object.getPrototypeOf(value);
1383
+ return prototype === Object.prototype || prototype === null ? 'plain-object' : 'unsupported';
1384
+ };
1385
+ const getMemo = (memo, a, b) => memo.get(a)?.get(b);
1386
+ const setMemo = (memo, a, b, value) => {
1387
+ let comparisons = memo.get(a);
1388
+ if (!comparisons) {
1389
+ comparisons = new WeakMap();
1390
+ memo.set(a, comparisons);
1391
+ }
1392
+ comparisons.set(b, value);
1393
+ };
1394
+ const memoizePair = (memo, a, b, value) => {
1395
+ setMemo(memo, a, b, value);
1396
+ setMemo(memo, b, a, value);
1397
+ };
1213
1398
  /**
1214
- * Recursively checks the equality of two objects, including nested structures and special cases
1215
- * such as arrays, sets, maps, dates, and NaN.
1399
+ * Recursively compares primitives, arrays, plain objects, Date, Map, and Set values.
1216
1400
  *
1217
- * `Map` and `Set` values are compared in iteration order because they are materialized into arrays
1218
- * before comparison. This keeps the helper predictable for transport/state snapshots, but it is not
1219
- * a mathematical set-equivalence check.
1401
+ * Map and Set values are compared in iteration order. Object comparison includes own enumerable
1402
+ * string keys only. Symbol and non-enumerable object keys are intentionally excluded.
1220
1403
  *
1221
- * @param {AnyType} a - The first object to compare.
1222
- * @param {AnyType} b - The second object to compare.
1223
- * @param {Map} [seen=new Map()] - A map used to detect cyclic references
1224
- * during deep equality check.
1225
- * @return {boolean} - Returns true if the objects are deeply equal, otherwise returns false.
1404
+ * @param {AnyType} a - The first value to compare.
1405
+ * @param {AnyType} b - The second value to compare.
1406
+ * @return {boolean} - Returns true if the supported values are deeply equal, otherwise false.
1226
1407
  */
1227
- function deepEqual(a, b, seen = new Map()) {
1228
- if (a === b) {
1408
+ const deepEqualInternal = (a, b, memo, activeLeftToRight, activeRightToLeft) => {
1409
+ if (Object.is(a, b))
1229
1410
  return true;
1230
- }
1231
- if (Number.isNaN(a) && Number.isNaN(b)) {
1232
- return true;
1233
- }
1234
- if (a && b && typeof a === 'object' && typeof b === 'object') {
1235
- if (seen.get(a) === b) {
1236
- return true;
1237
- }
1238
- seen.set(a, b);
1239
- if (Array.isArray(a)) {
1240
- if (!Array.isArray(b) || a.length !== b.length) {
1241
- return false;
1242
- }
1243
- for (let i = 0; i < a.length; i++) {
1244
- if (!deepEqual(a[i], b[i], seen)) {
1245
- return false;
1246
- }
1247
- }
1248
- return true;
1249
- }
1250
- if (a instanceof Date || a instanceof RegExp) {
1251
- return String(a) === String(b);
1252
- }
1253
- if (a instanceof Map) {
1254
- return deepEqual(Array.from(a.entries()), Array.from(b.entries()), seen);
1255
- }
1256
- if (a instanceof Set) {
1257
- return deepEqual(Array.from(a.values()), Array.from(b.values()), seen);
1258
- }
1259
- const keysA = Object.keys(a);
1260
- if (keysA.length !== Object.keys(b).length)
1261
- return false;
1262
- for (const k of keysA)
1263
- if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEqual(a[k], b[k], seen))
1264
- return false;
1265
- return true;
1266
- }
1267
- return false;
1411
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object')
1412
+ return false;
1413
+ const kindA = getComparableKind(a);
1414
+ const kindB = getComparableKind(b);
1415
+ if (kindA !== kindB || kindA === 'unsupported')
1416
+ return false;
1417
+ const cached = getMemo(memo, a, b);
1418
+ if (cached !== undefined)
1419
+ return cached;
1420
+ const activeMatch = activeLeftToRight.get(a);
1421
+ if (activeMatch || activeRightToLeft.has(b)) {
1422
+ return activeMatch === b && activeRightToLeft.get(b) === a;
1423
+ }
1424
+ activeLeftToRight.set(a, b);
1425
+ activeRightToLeft.set(b, a);
1426
+ let result = false;
1427
+ switch (kindA) {
1428
+ case 'array':
1429
+ result =
1430
+ a.length === b.length &&
1431
+ a.every((item, index) => deepEqualInternal(item, b[index], memo, activeLeftToRight, activeRightToLeft));
1432
+ break;
1433
+ case 'date':
1434
+ result = Object.is(a.getTime(), b.getTime());
1435
+ break;
1436
+ case 'map':
1437
+ result = deepEqualInternal(Array.from(a.entries()), Array.from(b.entries()), memo, activeLeftToRight, activeRightToLeft);
1438
+ break;
1439
+ case 'set':
1440
+ result = deepEqualInternal(Array.from(a.values()), Array.from(b.values()), memo, activeLeftToRight, activeRightToLeft);
1441
+ break;
1442
+ case 'plain-object': {
1443
+ const keysA = Object.keys(a);
1444
+ const keysB = Object.keys(b);
1445
+ result =
1446
+ keysA.length === keysB.length &&
1447
+ keysA.every((key) => Object.prototype.hasOwnProperty.call(b, key) &&
1448
+ deepEqualInternal(a[key], b[key], memo, activeLeftToRight, activeRightToLeft));
1449
+ break;
1450
+ }
1451
+ }
1452
+ activeLeftToRight.delete(a);
1453
+ activeRightToLeft.delete(b);
1454
+ memoizePair(memo, a, b, result);
1455
+ return result;
1456
+ };
1457
+ function deepEqual(a, b) {
1458
+ return deepEqualInternal(a, b, new WeakMap(), new WeakMap(), new WeakMap());
1268
1459
  }
1269
1460
 
1270
1461
  /**
@@ -1423,7 +1614,7 @@ class Serializer {
1423
1614
  }
1424
1615
  if (typedField?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
1425
1616
  const nullableVal = value ?? null;
1426
- return this.config.mapNullable.format?.(nullableVal) || this.config.mapNullable.replaceWith || nullableVal;
1617
+ return this.config.mapNullable.format?.(nullableVal) ?? this.config.mapNullable.replaceWith ?? nullableVal;
1427
1618
  }
1428
1619
  if (typedField?.type === 'string') {
1429
1620
  return serializeString(this.config, value);
@@ -1494,27 +1685,40 @@ class Serializer {
1494
1685
  }
1495
1686
  }
1496
1687
  if (typedField?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
1497
- return this.config.mapNullable.parse?.(value) || value;
1688
+ return this.config.mapNullable.parse?.(value) ?? value;
1689
+ }
1690
+ if (typedField?.type === 'boolean') {
1691
+ const parsed = this.config.mapBoolean.parse?.(value);
1692
+ if (parsed !== undefined) {
1693
+ return parsed;
1694
+ }
1695
+ if (value === true || value === false) {
1696
+ return value;
1697
+ }
1698
+ if (value === this.config.mapBoolean.true || value === 'true') {
1699
+ return true;
1700
+ }
1701
+ if (value === this.config.mapBoolean.false || value === 'false') {
1702
+ return false;
1703
+ }
1704
+ throw new SerializerFieldError(key || '', 'parse', new Error(`Unknown boolean value: ${String(value)}`));
1498
1705
  }
1499
- if (typedField?.type === 'boolean' ||
1500
- typeof value === 'boolean' ||
1501
- value === this.config.mapBoolean.true ||
1502
- value === this.config.mapBoolean.false) {
1706
+ if (typeof value === 'boolean' || value === this.config.mapBoolean.true || value === this.config.mapBoolean.false) {
1503
1707
  return this.config.mapBoolean.parse?.(value) ?? (value === this.config.mapBoolean.true || value === true);
1504
1708
  }
1505
1709
  const maybeDate = parseToDate(value, this.config.mapDate.dateFormat);
1506
1710
  if (typedField?.type === 'date' || maybeDate) {
1507
- return this.config.mapDate.parse?.(value) || maybeDate;
1711
+ return this.config.mapDate.parse?.(value) ?? maybeDate;
1508
1712
  }
1509
1713
  const periodTransform = this.config.mapPeriod.transformMode;
1510
1714
  if (periodTransform.mode === 'join') {
1511
1715
  const maybePeriod = parseToDatePeriod(value, this.config.mapPeriod.dateFormat);
1512
1716
  if (typedField?.type === 'period' || (maybePeriod || []).some(Boolean)) {
1513
- return this.config.mapPeriod.parse?.(value) || maybePeriod;
1717
+ return this.config.mapPeriod.parse?.(value) ?? maybePeriod;
1514
1718
  }
1515
1719
  }
1516
1720
  if (typedField?.type === 'number' || isNumber(value, this.config.mapNumber.fromString)) {
1517
- return this.config.mapNumber.parse?.(value) || Number(String(value).trim());
1721
+ return this.config.mapNumber.parse?.(value) ?? Number(String(value).trim());
1518
1722
  }
1519
1723
  if (typedField?.type === 'string' || typeof value === 'string') {
1520
1724
  const parsed = this.config.mapString.parse?.(value);
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.5",
2
+ "version": "2.0.7",
3
3
  "name": "@reforgium/internal",
4
4
  "description": "Hidden Reforgium foundation package for shared primitives, tokens, and infrastructure.",
5
5
  "author": "rtommievich",
@@ -25,9 +25,6 @@
25
25
  "foundation",
26
26
  "infrastructure"
27
27
  ],
28
- "types": "./src/public-api.ts",
29
- "typings": "types/reforgium-internal.d.ts",
30
- "module": "fesm2022/reforgium-internal.mjs",
31
28
  "files": [
32
29
  "fesm2022/*.mjs",
33
30
  "types/*.d.ts",
@@ -36,6 +33,14 @@
36
33
  "CHANGELOG.md",
37
34
  "LICENSE*"
38
35
  ],
36
+ "dependencies": {
37
+ "tslib": "^2.8.1"
38
+ },
39
+ "peerDependencies": {
40
+ "@angular/core": ">=18.0.0"
41
+ },
42
+ "module": "fesm2022/reforgium-internal.mjs",
43
+ "typings": "types/reforgium-internal.d.ts",
39
44
  "exports": {
40
45
  "./package.json": {
41
46
  "default": "./package.json"
@@ -44,11 +49,5 @@
44
49
  "types": "./types/reforgium-internal.d.ts",
45
50
  "default": "./fesm2022/reforgium-internal.mjs"
46
51
  }
47
- },
48
- "dependencies": {
49
- "tslib": "^2.8.1"
50
- },
51
- "peerDependencies": {
52
- "@angular/core": ">=19.0.0"
53
52
  }
54
53
  }
@@ -271,6 +271,7 @@ declare class LocalStorage<Key = string, Type extends AnyType = AnyDict> impleme
271
271
  set(key: Key, value: Type): void;
272
272
  remove(key: Key): void;
273
273
  clear(): void;
274
+ private get storage();
274
275
  private getSafePrefix;
275
276
  private safePrefix;
276
277
  }
@@ -311,6 +312,7 @@ declare class SessionStorage<Key = string, Type extends AnyType = AnyDict> imple
311
312
  set(key: Key, value: Type): void;
312
313
  remove(key: Key): void;
313
314
  clear(): void;
315
+ private get storage();
314
316
  private getSafePrefix;
315
317
  private safePrefix;
316
318
  }
@@ -650,7 +652,7 @@ declare const toDate: (v: unknown) => Date;
650
652
  * Converts a date string from a given format to ISO (UTC).
651
653
  * Example:
652
654
  * reformatDateToISO("24.11.2025", "dd.MM.yyyy")
653
- * "2025-11-24T00:00:00.000Z"
655
+ * > "2025-11-24T00:00:00.000Z"
654
656
  */
655
657
  declare function reformatDateToISO(dateStr?: string | null, mask?: string | null): string | null;
656
658
 
@@ -729,9 +731,15 @@ type ParseQueryParams = {
729
731
  (query: string): Record<string, string>;
730
732
  (query: string, arrayMode: QueryArrayMode): Record<string, string | string[]>;
731
733
  };
734
+ type FillUrlWithParamsOptions = {
735
+ missingParam?: 'empty' | 'keep' | 'throw';
736
+ preserveSlash?: boolean;
737
+ };
732
738
  /**
733
739
  * Normalizes a given URL by removing redundant slashes, resolving dot segments,
734
- * and ensuring there are no trailing slashes at the end of the URL.
740
+ * and ensuring there are no trailing slashes at the end of the pathname.
741
+ *
742
+ * Query strings and hashes are preserved byte-for-byte.
735
743
  *
736
744
  * @param {string} url - The URL string to normalize.
737
745
  * @return {string} - The normalized URL string.
@@ -744,10 +752,11 @@ declare function normalizeUrl(url: string): string;
744
752
  * @param {string} template URL template containing placeholders to replace.
745
753
  * @param {AnyDict} [params] Optional dictionary containing key-value pairs.
746
754
  * Keys correspond to placeholders in the template, and values will replace them.
755
+ * @param {FillUrlWithParamsOptions} [options] Placeholder materialization options.
747
756
  * @return {string} URL with placeholders replaced by corresponding values from the params object.
748
757
  * Defaults to the original template if params is not provided.
749
758
  */
750
- declare function fillUrlWithParams(template: string, params?: AnyDict): string;
759
+ declare function fillUrlWithParams(template: string, params?: AnyDict, options?: FillUrlWithParamsOptions): string;
751
760
  /**
752
761
  * Low-level URL helper for appending query parameters to an existing URL.
753
762
  *
@@ -803,7 +812,7 @@ declare const makeQuery: (object: AnyDict, concatType: QueryArrayMode, resolveCo
803
812
  * @param {string} pureRoute - Template route path to match, which may include placeholders
804
813
  * (e.g., `:id`).
805
814
  * @param {boolean} [withSubroute=false] - Determines whether to allow subroute matching. If true,
806
- * actualRoute must start with the filled pureRoute.
815
+ * actualRoute must match the template exactly or continue on a segment boundary.
807
816
  * @return {boolean} Returns true if the actual route matches the template
808
817
  * route (and optional subroute condition if enabled), otherwise false.
809
818
  */
@@ -899,21 +908,7 @@ declare function normalizeSortInput<Field extends string = string>(sort: SortInp
899
908
  */
900
909
  declare function sortInputToTokens<Field extends string = string>(sort: SortInput<Field>): SortToken<Field>[];
901
910
 
902
- /**
903
- * Recursively checks the equality of two objects, including nested structures and special cases
904
- * such as arrays, sets, maps, dates, and NaN.
905
- *
906
- * `Map` and `Set` values are compared in iteration order because they are materialized into arrays
907
- * before comparison. This keeps the helper predictable for transport/state snapshots, but it is not
908
- * a mathematical set-equivalence check.
909
- *
910
- * @param {AnyType} a - The first object to compare.
911
- * @param {AnyType} b - The second object to compare.
912
- * @param {Map} [seen=new Map()] - A map used to detect cyclic references
913
- * during deep equality check.
914
- * @return {boolean} - Returns true if the objects are deeply equal, otherwise returns false.
915
- */
916
- declare function deepEqual(a: AnyType, b: AnyType, seen?: Map<any, any>): boolean;
911
+ declare function deepEqual(a: AnyType, b: AnyType): boolean;
917
912
 
918
913
  /**
919
914
  * Generates a short pseudo-random identifier.