@naturalcycles/js-lib 14.257.0 → 14.258.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+ /**
3
+ * Service to generate, maintain, persist a stable "device id".
4
+ *
5
+ * It's called "device id" and not userId/visitorId, to indicate that it only identifies a device,
6
+ * and has nothing to do with user identification.
7
+ * User might be logged in or not.
8
+ * User id can be the same on multiple devices.
9
+ * DeviceId is unique per device, same User or not.
10
+ *
11
+ * Service provides methods to deterministically select fraction of devices.
12
+ * For example, select 10% of devices that visit the website to be tracked by Analytics
13
+ * (to reduce Analytics quota usage).
14
+ * DeviceId persistence will ensure that recurring visits from the same device will yield the same
15
+ * DeviceId, and same "selection assignment" (like an assignment in an AB test).
16
+ *
17
+ * @experimental
18
+ */
19
+ export declare class DeviceIdService {
20
+ constructor(cfg?: DeviceIdServiceCfg);
21
+ cfg: Required<DeviceIdServiceCfg>;
22
+ /**
23
+ * `deviceId` is null only in anomalous cases, e.g when localStorage is not available (due to e.g "out of disk space" on device).
24
+ * In all other cases it should be defined and stable (persisted indefinitely between multiple visits).
25
+ *
26
+ * It is null if the service is run on the server side.
27
+ */
28
+ deviceId: string | null;
29
+ /**
30
+ * Selects this device based on "deterministic random selection", according to the defined `rate`.
31
+ * Rate is a floating number between 0 and 1.
32
+ * E.g rate of 0.1 means 10% chance of being selected.
33
+ *
34
+ * Selection is based on deviceId, which is generated random and persisted between visits.
35
+ * Persistence ensures that the selection (similar to an AB-test assignment) "sticks" to the device.
36
+ *
37
+ * If deviceId failed to be generated, e.g due to Device running out-of-space to save a string to localStorage,
38
+ * it will NOT be selected.
39
+ *
40
+ * @returns true if the device is selected.
41
+ */
42
+ select(rate: number): boolean;
43
+ /**
44
+ * Deletes the persisted deviceId.
45
+ * Keeps it in the service.
46
+ * To remove it from the service, assign deviceIdService.deviceId = null.
47
+ */
48
+ clearPersistence(): void;
49
+ /**
50
+ * Generates a stable Device id if it wasn't previously generated on this device.
51
+ * Otherwise, reads a Device id from persistent storage.
52
+ */
53
+ private init;
54
+ private debug;
55
+ }
56
+ export interface DeviceIdServiceCfg {
57
+ /**
58
+ * Default: deviceId
59
+ */
60
+ localStorageKey?: string;
61
+ /**
62
+ * Set to true to enable debug logging.
63
+ */
64
+ debug?: boolean;
65
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /// <reference lib="dom" preserve="true" />
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.DeviceIdService = void 0;
5
+ const env_1 = require("./env");
6
+ const nanoid_1 = require("./nanoid");
7
+ const hash_util_1 = require("./string/hash.util");
8
+ // This is in sync with the default length in Nanoid.
9
+ const deviceIdLength = 21;
10
+ /**
11
+ * Service to generate, maintain, persist a stable "device id".
12
+ *
13
+ * It's called "device id" and not userId/visitorId, to indicate that it only identifies a device,
14
+ * and has nothing to do with user identification.
15
+ * User might be logged in or not.
16
+ * User id can be the same on multiple devices.
17
+ * DeviceId is unique per device, same User or not.
18
+ *
19
+ * Service provides methods to deterministically select fraction of devices.
20
+ * For example, select 10% of devices that visit the website to be tracked by Analytics
21
+ * (to reduce Analytics quota usage).
22
+ * DeviceId persistence will ensure that recurring visits from the same device will yield the same
23
+ * DeviceId, and same "selection assignment" (like an assignment in an AB test).
24
+ *
25
+ * @experimental
26
+ */
27
+ class DeviceIdService {
28
+ constructor(cfg = {}) {
29
+ this.cfg = {
30
+ localStorageKey: 'deviceId',
31
+ debug: false,
32
+ ...cfg,
33
+ };
34
+ this.init();
35
+ }
36
+ /**
37
+ * Selects this device based on "deterministic random selection", according to the defined `rate`.
38
+ * Rate is a floating number between 0 and 1.
39
+ * E.g rate of 0.1 means 10% chance of being selected.
40
+ *
41
+ * Selection is based on deviceId, which is generated random and persisted between visits.
42
+ * Persistence ensures that the selection (similar to an AB-test assignment) "sticks" to the device.
43
+ *
44
+ * If deviceId failed to be generated, e.g due to Device running out-of-space to save a string to localStorage,
45
+ * it will NOT be selected.
46
+ *
47
+ * @returns true if the device is selected.
48
+ */
49
+ select(rate) {
50
+ if (!this.deviceId) {
51
+ this.debug(`deviceId is null, skipping selection`);
52
+ return false;
53
+ }
54
+ const mod = Math.trunc(rate * 1000);
55
+ // console.log('hash: ', hashCode(this.deviceId)) // todo
56
+ return (0, hash_util_1.hashCode)(this.deviceId) % 1000 < mod;
57
+ }
58
+ /**
59
+ * Deletes the persisted deviceId.
60
+ * Keeps it in the service.
61
+ * To remove it from the service, assign deviceIdService.deviceId = null.
62
+ */
63
+ clearPersistence() {
64
+ try {
65
+ globalThis.localStorage.removeItem(this.cfg.localStorageKey);
66
+ }
67
+ catch (err) {
68
+ console.log(err);
69
+ }
70
+ }
71
+ /**
72
+ * Generates a stable Device id if it wasn't previously generated on this device.
73
+ * Otherwise, reads a Device id from persistent storage.
74
+ */
75
+ init() {
76
+ this.deviceId = null;
77
+ if ((0, env_1.isServerSide)())
78
+ return;
79
+ try {
80
+ this.deviceId = globalThis.localStorage.getItem(this.cfg.localStorageKey);
81
+ if (this.deviceId)
82
+ this.debug(`loaded deviceId: ${this.deviceId}`);
83
+ }
84
+ catch (err) {
85
+ console.log(err);
86
+ this.deviceId = null;
87
+ }
88
+ if (this.deviceId && this.deviceId.length !== deviceIdLength) {
89
+ console.warn(`[DeviceIdService] unexpected deviceIdLength (${this.deviceId.length}), will re-generate the id`, { deviceId: this.deviceId });
90
+ this.deviceId = null;
91
+ }
92
+ if (!this.deviceId) {
93
+ try {
94
+ this.deviceId = (0, nanoid_1.nanoidBrowser)(deviceIdLength);
95
+ this.debug(`generated new deviceId: ${this.deviceId}`);
96
+ globalThis.localStorage.setItem(this.cfg.localStorageKey, this.deviceId);
97
+ }
98
+ catch (err) {
99
+ console.log(err);
100
+ this.deviceId = null;
101
+ }
102
+ }
103
+ }
104
+ debug(...args) {
105
+ if (this.cfg.debug)
106
+ console.log('[DeviceIdService]', ...args);
107
+ }
108
+ }
109
+ exports.DeviceIdService = DeviceIdService;
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export * from './decorators/memoFnAsync';
27
27
  export * from './decorators/retry.decorator';
28
28
  export * from './decorators/timeout.decorator';
29
29
  export * from './define';
30
+ export * from './deviceIdService';
30
31
  export * from './enum.util';
31
32
  export * from './env';
32
33
  export * from './env/buildInfo';
@@ -52,6 +53,7 @@ export * from './log/commonLogger';
52
53
  export * from './math/math.util';
53
54
  export * from './math/sma';
54
55
  export * from './math/stack.util';
56
+ export * from './nanoid';
55
57
  export * from './number/createDeterministicRandom';
56
58
  export * from './number/number.util';
57
59
  export * from './object/deepEquals';
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ tslib_1.__exportStar(require("./decorators/memoFnAsync"), exports);
31
31
  tslib_1.__exportStar(require("./decorators/retry.decorator"), exports);
32
32
  tslib_1.__exportStar(require("./decorators/timeout.decorator"), exports);
33
33
  tslib_1.__exportStar(require("./define"), exports);
34
+ tslib_1.__exportStar(require("./deviceIdService"), exports);
34
35
  tslib_1.__exportStar(require("./enum.util"), exports);
35
36
  tslib_1.__exportStar(require("./env"), exports);
36
37
  tslib_1.__exportStar(require("./env/buildInfo"), exports);
@@ -56,6 +57,7 @@ tslib_1.__exportStar(require("./log/commonLogger"), exports);
56
57
  tslib_1.__exportStar(require("./math/math.util"), exports);
57
58
  tslib_1.__exportStar(require("./math/sma"), exports);
58
59
  tslib_1.__exportStar(require("./math/stack.util"), exports);
60
+ tslib_1.__exportStar(require("./nanoid"), exports);
59
61
  tslib_1.__exportStar(require("./number/createDeterministicRandom"), exports);
60
62
  tslib_1.__exportStar(require("./number/number.util"), exports);
61
63
  tslib_1.__exportStar(require("./object/deepEquals"), exports);
@@ -0,0 +1,7 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+ /**
3
+ * Function that takes a length (defaults to 21) and generates a random string id of that length.
4
+ */
5
+ export type NanoidFunction = (length?: number) => string;
6
+ export declare function nanoidBrowser(length?: number): string;
7
+ export declare function nanoidBrowserCustomAlphabet(alphabet: string, length?: number): NanoidFunction;
package/dist/nanoid.js ADDED
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ // Vendored from https://github.com/ai/nanoid/blob/main/index.browser.js
3
+ // All credit to nanoid authors: https://github.com/ai/nanoid
4
+ // Reason for vendoring: (still) cannot import esm, and Nanoid went ESM-only since 4.0
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.nanoidBrowser = nanoidBrowser;
7
+ exports.nanoidBrowserCustomAlphabet = nanoidBrowserCustomAlphabet;
8
+ /// <reference lib="dom" preserve="true" />
9
+ /* eslint-disable no-bitwise */
10
+ // "0-9a-zA-Z-_", same as base64url alphabet
11
+ const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
12
+ function nanoidBrowser(length = 21) {
13
+ let id = '';
14
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length));
15
+ while (length--) {
16
+ // Using the bitwise AND operator to "cap" the value of
17
+ // the random byte from 255 to 63, in that way we can make sure
18
+ // that the value will be a valid index for the "chars" string.
19
+ id += urlAlphabet[bytes[length] & 63];
20
+ }
21
+ return id;
22
+ }
23
+ const defaultRandomFunction = (bytes) => globalThis.crypto.getRandomValues(new Uint8Array(bytes));
24
+ function nanoidBrowserCustomAlphabet(alphabet, length = 21) {
25
+ return customRandom(alphabet, length, defaultRandomFunction);
26
+ }
27
+ function customRandom(alphabet, defaultSize, getRandom) {
28
+ // First, a bitmask is necessary to generate the ID. The bitmask makes bytes
29
+ // values closer to the alphabet size. The bitmask calculates the closest
30
+ // `2^31 - 1` number, which exceeds the alphabet size.
31
+ // For example, the bitmask for the alphabet size 30 is 31 (00011111).
32
+ // `Math.clz32` is not used, because it is not available in browsers.
33
+ const mask = (2 << Math.log2(alphabet.length - 1)) - 1;
34
+ // Though, the bitmask solution is not perfect since the bytes exceeding
35
+ // the alphabet size are refused. Therefore, to reliably generate the ID,
36
+ // the random bytes redundancy has to be satisfied.
37
+ // Note: every hardware random generator call is performance expensive,
38
+ // because the system call for entropy collection takes a lot of time.
39
+ // So, to avoid additional system calls, extra bytes are requested in advance.
40
+ // Next, a step determines how many random bytes to generate.
41
+ // The number of random bytes gets decided upon the ID size, mask,
42
+ // alphabet size, and magic number 1.6 (using 1.6 peaks at performance
43
+ // according to benchmarks).
44
+ // `-~f => Math.ceil(f)` if f is a float
45
+ // `-~i => i + 1` if i is an integer
46
+ const step = -~((1.6 * mask * defaultSize) / alphabet.length);
47
+ return (size = defaultSize) => {
48
+ let id = '';
49
+ while (true) {
50
+ const bytes = getRandom(step);
51
+ // A compact alternative for `for (var i = 0; i < step; i++)`.
52
+ let j = step;
53
+ while (j--) {
54
+ // Adding `|| ''` refuses a random byte that exceeds the alphabet size.
55
+ id += alphabet[bytes[j] & mask] || '';
56
+ if (id.length === size)
57
+ return id;
58
+ }
59
+ }
60
+ };
61
+ }
@@ -1,6 +1,11 @@
1
+ /**
2
+ * Function that returns a random number between 0 and 1.
3
+ * Exactly same signature as Math.random function.
4
+ */
5
+ export type RandomFunction = () => number;
1
6
  /**
2
7
  * Returns a "deterministic Math.random() function"
3
8
  *
4
9
  * Based on: https://gist.github.com/mathiasbynens/5670917
5
10
  */
6
- export declare function _createDeterministicRandom(): () => number;
11
+ export declare function _createDeterministicRandom(seed?: number): RandomFunction;
@@ -7,8 +7,7 @@ exports._createDeterministicRandom = _createDeterministicRandom;
7
7
  *
8
8
  * Based on: https://gist.github.com/mathiasbynens/5670917
9
9
  */
10
- function _createDeterministicRandom() {
11
- let seed = 0x2f6e2b1;
10
+ function _createDeterministicRandom(seed = 0x2f6e2b1) {
12
11
  return () => {
13
12
  // Robert Jenkins’ 32 bit integer hash function
14
13
  seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff;
@@ -6,7 +6,7 @@ import { Integer } from '../types';
6
6
  *
7
7
  * 1. Performance
8
8
  * 2. For non-cryptographic use (where accidental collision is not the end-of-the-world)
9
- * 3. Compact size (32 bits max, versus 128 in md5; presented in less string json-safe characters)
9
+ * 3. Compact size (32 bits max, versus 128 in md5; presented in smaller number of string json-safe characters)
10
10
  *
11
11
  * Basically, these functions are as simple as they can be, but still "random enough" for
12
12
  * normal non-cryptographic use cases.
@@ -14,7 +14,7 @@ const BASE64URL = BASE62 + '-_';
14
14
  *
15
15
  * 1. Performance
16
16
  * 2. For non-cryptographic use (where accidental collision is not the end-of-the-world)
17
- * 3. Compact size (32 bits max, versus 128 in md5; presented in less string json-safe characters)
17
+ * 3. Compact size (32 bits max, versus 128 in md5; presented in smaller number of string json-safe characters)
18
18
  *
19
19
  * Basically, these functions are as simple as they can be, but still "random enough" for
20
20
  * normal non-cryptographic use cases.
package/dist/web.d.ts CHANGED
@@ -5,6 +5,12 @@ import { StringMap } from './types';
5
5
  * Implements WebStorage API by using in-memory storage.
6
6
  * Can be useful in SSR environment or unit tests.
7
7
  *
8
+ * This is how localStorage can be mocked in Node:
9
+ *
10
+ * Object.assign(globalThis, {
11
+ * localStorage: new InMemoryWebStorage(),
12
+ * })
13
+ *
8
14
  * @experimental
9
15
  */
10
16
  export declare class InMemoryWebStorage implements Storage {
package/dist/web.js CHANGED
@@ -7,6 +7,12 @@ exports.InMemoryWebStorage = void 0;
7
7
  * Implements WebStorage API by using in-memory storage.
8
8
  * Can be useful in SSR environment or unit tests.
9
9
  *
10
+ * This is how localStorage can be mocked in Node:
11
+ *
12
+ * Object.assign(globalThis, {
13
+ * localStorage: new InMemoryWebStorage(),
14
+ * })
15
+ *
10
16
  * @experimental
11
17
  */
12
18
  class InMemoryWebStorage {
@@ -0,0 +1,105 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+ import { isServerSide } from './env';
3
+ import { nanoidBrowser } from './nanoid';
4
+ import { hashCode } from './string/hash.util';
5
+ // This is in sync with the default length in Nanoid.
6
+ const deviceIdLength = 21;
7
+ /**
8
+ * Service to generate, maintain, persist a stable "device id".
9
+ *
10
+ * It's called "device id" and not userId/visitorId, to indicate that it only identifies a device,
11
+ * and has nothing to do with user identification.
12
+ * User might be logged in or not.
13
+ * User id can be the same on multiple devices.
14
+ * DeviceId is unique per device, same User or not.
15
+ *
16
+ * Service provides methods to deterministically select fraction of devices.
17
+ * For example, select 10% of devices that visit the website to be tracked by Analytics
18
+ * (to reduce Analytics quota usage).
19
+ * DeviceId persistence will ensure that recurring visits from the same device will yield the same
20
+ * DeviceId, and same "selection assignment" (like an assignment in an AB test).
21
+ *
22
+ * @experimental
23
+ */
24
+ export class DeviceIdService {
25
+ constructor(cfg = {}) {
26
+ this.cfg = {
27
+ localStorageKey: 'deviceId',
28
+ debug: false,
29
+ ...cfg,
30
+ };
31
+ this.init();
32
+ }
33
+ /**
34
+ * Selects this device based on "deterministic random selection", according to the defined `rate`.
35
+ * Rate is a floating number between 0 and 1.
36
+ * E.g rate of 0.1 means 10% chance of being selected.
37
+ *
38
+ * Selection is based on deviceId, which is generated random and persisted between visits.
39
+ * Persistence ensures that the selection (similar to an AB-test assignment) "sticks" to the device.
40
+ *
41
+ * If deviceId failed to be generated, e.g due to Device running out-of-space to save a string to localStorage,
42
+ * it will NOT be selected.
43
+ *
44
+ * @returns true if the device is selected.
45
+ */
46
+ select(rate) {
47
+ if (!this.deviceId) {
48
+ this.debug(`deviceId is null, skipping selection`);
49
+ return false;
50
+ }
51
+ const mod = Math.trunc(rate * 1000);
52
+ // console.log('hash: ', hashCode(this.deviceId)) // todo
53
+ return hashCode(this.deviceId) % 1000 < mod;
54
+ }
55
+ /**
56
+ * Deletes the persisted deviceId.
57
+ * Keeps it in the service.
58
+ * To remove it from the service, assign deviceIdService.deviceId = null.
59
+ */
60
+ clearPersistence() {
61
+ try {
62
+ globalThis.localStorage.removeItem(this.cfg.localStorageKey);
63
+ }
64
+ catch (err) {
65
+ console.log(err);
66
+ }
67
+ }
68
+ /**
69
+ * Generates a stable Device id if it wasn't previously generated on this device.
70
+ * Otherwise, reads a Device id from persistent storage.
71
+ */
72
+ init() {
73
+ this.deviceId = null;
74
+ if (isServerSide())
75
+ return;
76
+ try {
77
+ this.deviceId = globalThis.localStorage.getItem(this.cfg.localStorageKey);
78
+ if (this.deviceId)
79
+ this.debug(`loaded deviceId: ${this.deviceId}`);
80
+ }
81
+ catch (err) {
82
+ console.log(err);
83
+ this.deviceId = null;
84
+ }
85
+ if (this.deviceId && this.deviceId.length !== deviceIdLength) {
86
+ console.warn(`[DeviceIdService] unexpected deviceIdLength (${this.deviceId.length}), will re-generate the id`, { deviceId: this.deviceId });
87
+ this.deviceId = null;
88
+ }
89
+ if (!this.deviceId) {
90
+ try {
91
+ this.deviceId = nanoidBrowser(deviceIdLength);
92
+ this.debug(`generated new deviceId: ${this.deviceId}`);
93
+ globalThis.localStorage.setItem(this.cfg.localStorageKey, this.deviceId);
94
+ }
95
+ catch (err) {
96
+ console.log(err);
97
+ this.deviceId = null;
98
+ }
99
+ }
100
+ }
101
+ debug(...args) {
102
+ if (this.cfg.debug)
103
+ console.log('[DeviceIdService]', ...args);
104
+ }
105
+ }
package/dist-esm/index.js CHANGED
@@ -27,6 +27,7 @@ export * from './decorators/memoFnAsync';
27
27
  export * from './decorators/retry.decorator';
28
28
  export * from './decorators/timeout.decorator';
29
29
  export * from './define';
30
+ export * from './deviceIdService';
30
31
  export * from './enum.util';
31
32
  export * from './env';
32
33
  export * from './env/buildInfo';
@@ -52,6 +53,7 @@ export * from './log/commonLogger';
52
53
  export * from './math/math.util';
53
54
  export * from './math/sma';
54
55
  export * from './math/stack.util';
56
+ export * from './nanoid';
55
57
  export * from './number/createDeterministicRandom';
56
58
  export * from './number/number.util';
57
59
  export * from './object/deepEquals';
@@ -0,0 +1,57 @@
1
+ // Vendored from https://github.com/ai/nanoid/blob/main/index.browser.js
2
+ // All credit to nanoid authors: https://github.com/ai/nanoid
3
+ // Reason for vendoring: (still) cannot import esm, and Nanoid went ESM-only since 4.0
4
+ /// <reference lib="dom" preserve="true" />
5
+ /* eslint-disable no-bitwise */
6
+ // "0-9a-zA-Z-_", same as base64url alphabet
7
+ const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
8
+ export function nanoidBrowser(length = 21) {
9
+ let id = '';
10
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length));
11
+ while (length--) {
12
+ // Using the bitwise AND operator to "cap" the value of
13
+ // the random byte from 255 to 63, in that way we can make sure
14
+ // that the value will be a valid index for the "chars" string.
15
+ id += urlAlphabet[bytes[length] & 63];
16
+ }
17
+ return id;
18
+ }
19
+ const defaultRandomFunction = (bytes) => globalThis.crypto.getRandomValues(new Uint8Array(bytes));
20
+ export function nanoidBrowserCustomAlphabet(alphabet, length = 21) {
21
+ return customRandom(alphabet, length, defaultRandomFunction);
22
+ }
23
+ function customRandom(alphabet, defaultSize, getRandom) {
24
+ // First, a bitmask is necessary to generate the ID. The bitmask makes bytes
25
+ // values closer to the alphabet size. The bitmask calculates the closest
26
+ // `2^31 - 1` number, which exceeds the alphabet size.
27
+ // For example, the bitmask for the alphabet size 30 is 31 (00011111).
28
+ // `Math.clz32` is not used, because it is not available in browsers.
29
+ const mask = (2 << Math.log2(alphabet.length - 1)) - 1;
30
+ // Though, the bitmask solution is not perfect since the bytes exceeding
31
+ // the alphabet size are refused. Therefore, to reliably generate the ID,
32
+ // the random bytes redundancy has to be satisfied.
33
+ // Note: every hardware random generator call is performance expensive,
34
+ // because the system call for entropy collection takes a lot of time.
35
+ // So, to avoid additional system calls, extra bytes are requested in advance.
36
+ // Next, a step determines how many random bytes to generate.
37
+ // The number of random bytes gets decided upon the ID size, mask,
38
+ // alphabet size, and magic number 1.6 (using 1.6 peaks at performance
39
+ // according to benchmarks).
40
+ // `-~f => Math.ceil(f)` if f is a float
41
+ // `-~i => i + 1` if i is an integer
42
+ const step = -~((1.6 * mask * defaultSize) / alphabet.length);
43
+ return (size = defaultSize) => {
44
+ let id = '';
45
+ while (true) {
46
+ const bytes = getRandom(step);
47
+ // A compact alternative for `for (var i = 0; i < step; i++)`.
48
+ let j = step;
49
+ while (j--) {
50
+ // Adding `|| ''` refuses a random byte that exceeds the alphabet size.
51
+ id += alphabet[bytes[j] & mask] || '';
52
+ if (id.length === size)
53
+ return id;
54
+ }
55
+ }
56
+ };
57
+ }
@@ -4,8 +4,7 @@
4
4
  *
5
5
  * Based on: https://gist.github.com/mathiasbynens/5670917
6
6
  */
7
- export function _createDeterministicRandom() {
8
- let seed = 0x2f6e2b1;
7
+ export function _createDeterministicRandom(seed = 0x2f6e2b1) {
9
8
  return () => {
10
9
  // Robert Jenkins’ 32 bit integer hash function
11
10
  seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff;
@@ -8,7 +8,7 @@ const BASE64URL = BASE62 + '-_';
8
8
  *
9
9
  * 1. Performance
10
10
  * 2. For non-cryptographic use (where accidental collision is not the end-of-the-world)
11
- * 3. Compact size (32 bits max, versus 128 in md5; presented in less string json-safe characters)
11
+ * 3. Compact size (32 bits max, versus 128 in md5; presented in smaller number of string json-safe characters)
12
12
  *
13
13
  * Basically, these functions are as simple as they can be, but still "random enough" for
14
14
  * normal non-cryptographic use cases.
package/dist-esm/web.js CHANGED
@@ -4,6 +4,12 @@
4
4
  * Implements WebStorage API by using in-memory storage.
5
5
  * Can be useful in SSR environment or unit tests.
6
6
  *
7
+ * This is how localStorage can be mocked in Node:
8
+ *
9
+ * Object.assign(globalThis, {
10
+ * localStorage: new InMemoryWebStorage(),
11
+ * })
12
+ *
7
13
  * @experimental
8
14
  */
9
15
  export class InMemoryWebStorage {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.257.0",
3
+ "version": "14.258.0",
4
4
  "scripts": {
5
5
  "prepare": "husky",
6
6
  "build": "dev-lib build-esm-cjs",
@@ -0,0 +1,137 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+
3
+ import { isServerSide } from './env'
4
+ import { nanoidBrowser } from './nanoid'
5
+ import { hashCode } from './string/hash.util'
6
+
7
+ // This is in sync with the default length in Nanoid.
8
+ const deviceIdLength = 21
9
+
10
+ /**
11
+ * Service to generate, maintain, persist a stable "device id".
12
+ *
13
+ * It's called "device id" and not userId/visitorId, to indicate that it only identifies a device,
14
+ * and has nothing to do with user identification.
15
+ * User might be logged in or not.
16
+ * User id can be the same on multiple devices.
17
+ * DeviceId is unique per device, same User or not.
18
+ *
19
+ * Service provides methods to deterministically select fraction of devices.
20
+ * For example, select 10% of devices that visit the website to be tracked by Analytics
21
+ * (to reduce Analytics quota usage).
22
+ * DeviceId persistence will ensure that recurring visits from the same device will yield the same
23
+ * DeviceId, and same "selection assignment" (like an assignment in an AB test).
24
+ *
25
+ * @experimental
26
+ */
27
+ export class DeviceIdService {
28
+ constructor(cfg: DeviceIdServiceCfg = {}) {
29
+ this.cfg = {
30
+ localStorageKey: 'deviceId',
31
+ debug: false,
32
+ ...cfg,
33
+ }
34
+
35
+ this.init()
36
+ }
37
+
38
+ cfg: Required<DeviceIdServiceCfg>
39
+
40
+ /**
41
+ * `deviceId` is null only in anomalous cases, e.g when localStorage is not available (due to e.g "out of disk space" on device).
42
+ * In all other cases it should be defined and stable (persisted indefinitely between multiple visits).
43
+ *
44
+ * It is null if the service is run on the server side.
45
+ */
46
+ deviceId!: string | null
47
+
48
+ /**
49
+ * Selects this device based on "deterministic random selection", according to the defined `rate`.
50
+ * Rate is a floating number between 0 and 1.
51
+ * E.g rate of 0.1 means 10% chance of being selected.
52
+ *
53
+ * Selection is based on deviceId, which is generated random and persisted between visits.
54
+ * Persistence ensures that the selection (similar to an AB-test assignment) "sticks" to the device.
55
+ *
56
+ * If deviceId failed to be generated, e.g due to Device running out-of-space to save a string to localStorage,
57
+ * it will NOT be selected.
58
+ *
59
+ * @returns true if the device is selected.
60
+ */
61
+ select(rate: number): boolean {
62
+ if (!this.deviceId) {
63
+ this.debug(`deviceId is null, skipping selection`)
64
+ return false
65
+ }
66
+
67
+ const mod = Math.trunc(rate * 1000)
68
+ // console.log('hash: ', hashCode(this.deviceId)) // todo
69
+
70
+ return hashCode(this.deviceId) % 1000 < mod
71
+ }
72
+
73
+ /**
74
+ * Deletes the persisted deviceId.
75
+ * Keeps it in the service.
76
+ * To remove it from the service, assign deviceIdService.deviceId = null.
77
+ */
78
+ clearPersistence(): void {
79
+ try {
80
+ globalThis.localStorage.removeItem(this.cfg.localStorageKey)
81
+ } catch (err) {
82
+ console.log(err)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Generates a stable Device id if it wasn't previously generated on this device.
88
+ * Otherwise, reads a Device id from persistent storage.
89
+ */
90
+ private init(): void {
91
+ this.deviceId = null
92
+ if (isServerSide()) return
93
+
94
+ try {
95
+ this.deviceId = globalThis.localStorage.getItem(this.cfg.localStorageKey)
96
+ if (this.deviceId) this.debug(`loaded deviceId: ${this.deviceId}`)
97
+ } catch (err) {
98
+ console.log(err)
99
+ this.deviceId = null
100
+ }
101
+
102
+ if (this.deviceId && this.deviceId.length !== deviceIdLength) {
103
+ console.warn(
104
+ `[DeviceIdService] unexpected deviceIdLength (${this.deviceId.length}), will re-generate the id`,
105
+ { deviceId: this.deviceId },
106
+ )
107
+ this.deviceId = null
108
+ }
109
+
110
+ if (!this.deviceId) {
111
+ try {
112
+ this.deviceId = nanoidBrowser(deviceIdLength)
113
+ this.debug(`generated new deviceId: ${this.deviceId}`)
114
+ globalThis.localStorage.setItem(this.cfg.localStorageKey, this.deviceId)
115
+ } catch (err) {
116
+ console.log(err)
117
+ this.deviceId = null
118
+ }
119
+ }
120
+ }
121
+
122
+ private debug(...args: any[]): void {
123
+ if (this.cfg.debug) console.log('[DeviceIdService]', ...args)
124
+ }
125
+ }
126
+
127
+ export interface DeviceIdServiceCfg {
128
+ /**
129
+ * Default: deviceId
130
+ */
131
+ localStorageKey?: string
132
+
133
+ /**
134
+ * Set to true to enable debug logging.
135
+ */
136
+ debug?: boolean
137
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export * from './decorators/memoFnAsync'
27
27
  export * from './decorators/retry.decorator'
28
28
  export * from './decorators/timeout.decorator'
29
29
  export * from './define'
30
+ export * from './deviceIdService'
30
31
  export * from './enum.util'
31
32
  export * from './env'
32
33
  export * from './env/buildInfo'
@@ -52,6 +53,7 @@ export * from './log/commonLogger'
52
53
  export * from './math/math.util'
53
54
  export * from './math/sma'
54
55
  export * from './math/stack.util'
56
+ export * from './nanoid'
55
57
  export * from './number/createDeterministicRandom'
56
58
  export * from './number/number.util'
57
59
  export * from './object/deepEquals'
package/src/nanoid.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Vendored from https://github.com/ai/nanoid/blob/main/index.browser.js
2
+ // All credit to nanoid authors: https://github.com/ai/nanoid
3
+ // Reason for vendoring: (still) cannot import esm, and Nanoid went ESM-only since 4.0
4
+
5
+ /// <reference lib="dom" preserve="true" />
6
+
7
+ /* eslint-disable no-bitwise */
8
+
9
+ // "0-9a-zA-Z-_", same as base64url alphabet
10
+ const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
11
+
12
+ /**
13
+ * Function that takes a length (defaults to 21) and generates a random string id of that length.
14
+ */
15
+ export type NanoidFunction = (length?: number) => string
16
+
17
+ type NanoidRandomFunction = (bytes: number) => Uint8Array
18
+
19
+ export function nanoidBrowser(length = 21): string {
20
+ let id = ''
21
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length))
22
+ while (length--) {
23
+ // Using the bitwise AND operator to "cap" the value of
24
+ // the random byte from 255 to 63, in that way we can make sure
25
+ // that the value will be a valid index for the "chars" string.
26
+ id += urlAlphabet[bytes[length]! & 63]
27
+ }
28
+ return id
29
+ }
30
+
31
+ const defaultRandomFunction: NanoidRandomFunction = (bytes: number) =>
32
+ globalThis.crypto.getRandomValues(new Uint8Array(bytes))
33
+
34
+ export function nanoidBrowserCustomAlphabet(alphabet: string, length = 21): NanoidFunction {
35
+ return customRandom(alphabet, length, defaultRandomFunction)
36
+ }
37
+
38
+ function customRandom(
39
+ alphabet: string,
40
+ defaultSize: number,
41
+ getRandom: NanoidRandomFunction,
42
+ ): NanoidFunction {
43
+ // First, a bitmask is necessary to generate the ID. The bitmask makes bytes
44
+ // values closer to the alphabet size. The bitmask calculates the closest
45
+ // `2^31 - 1` number, which exceeds the alphabet size.
46
+ // For example, the bitmask for the alphabet size 30 is 31 (00011111).
47
+ // `Math.clz32` is not used, because it is not available in browsers.
48
+ const mask = (2 << Math.log2(alphabet.length - 1)) - 1
49
+ // Though, the bitmask solution is not perfect since the bytes exceeding
50
+ // the alphabet size are refused. Therefore, to reliably generate the ID,
51
+ // the random bytes redundancy has to be satisfied.
52
+
53
+ // Note: every hardware random generator call is performance expensive,
54
+ // because the system call for entropy collection takes a lot of time.
55
+ // So, to avoid additional system calls, extra bytes are requested in advance.
56
+
57
+ // Next, a step determines how many random bytes to generate.
58
+ // The number of random bytes gets decided upon the ID size, mask,
59
+ // alphabet size, and magic number 1.6 (using 1.6 peaks at performance
60
+ // according to benchmarks).
61
+
62
+ // `-~f => Math.ceil(f)` if f is a float
63
+ // `-~i => i + 1` if i is an integer
64
+ const step = -~((1.6 * mask * defaultSize) / alphabet.length)
65
+
66
+ return (size = defaultSize) => {
67
+ let id = ''
68
+ while (true) {
69
+ const bytes = getRandom(step)
70
+ // A compact alternative for `for (var i = 0; i < step; i++)`.
71
+ let j = step
72
+ while (j--) {
73
+ // Adding `|| ''` refuses a random byte that exceeds the alphabet size.
74
+ id += alphabet[bytes[j]! & mask] || ''
75
+ if (id.length === size) return id
76
+ }
77
+ }
78
+ }
79
+ }
@@ -1,12 +1,17 @@
1
1
  /* eslint-disable no-bitwise */
2
2
 
3
+ /**
4
+ * Function that returns a random number between 0 and 1.
5
+ * Exactly same signature as Math.random function.
6
+ */
7
+ export type RandomFunction = () => number
8
+
3
9
  /**
4
10
  * Returns a "deterministic Math.random() function"
5
11
  *
6
12
  * Based on: https://gist.github.com/mathiasbynens/5670917
7
13
  */
8
- export function _createDeterministicRandom(): () => number {
9
- let seed = 0x2f6e2b1
14
+ export function _createDeterministicRandom(seed = 0x2f6e2b1): RandomFunction {
10
15
  return () => {
11
16
  // Robert Jenkins’ 32 bit integer hash function
12
17
  seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff
@@ -11,7 +11,7 @@ const BASE64URL = BASE62 + '-_'
11
11
  *
12
12
  * 1. Performance
13
13
  * 2. For non-cryptographic use (where accidental collision is not the end-of-the-world)
14
- * 3. Compact size (32 bits max, versus 128 in md5; presented in less string json-safe characters)
14
+ * 3. Compact size (32 bits max, versus 128 in md5; presented in smaller number of string json-safe characters)
15
15
  *
16
16
  * Basically, these functions are as simple as they can be, but still "random enough" for
17
17
  * normal non-cryptographic use cases.
package/src/web.ts CHANGED
@@ -7,6 +7,12 @@ import { StringMap } from './types'
7
7
  * Implements WebStorage API by using in-memory storage.
8
8
  * Can be useful in SSR environment or unit tests.
9
9
  *
10
+ * This is how localStorage can be mocked in Node:
11
+ *
12
+ * Object.assign(globalThis, {
13
+ * localStorage: new InMemoryWebStorage(),
14
+ * })
15
+ *
10
16
  * @experimental
11
17
  */
12
18
  export class InMemoryWebStorage implements Storage {