Skip to content

Commit

Permalink
Require Node.js 18 and move to ESM
Browse files Browse the repository at this point in the history
Fixes #86
Fixes #85
Fixes #83
Fixes #79
Fixes #52
  • Loading branch information
sindresorhus committed Jan 26, 2025
1 parent 8a3c05d commit 35487b2
Show file tree
Hide file tree
Showing 14 changed files with 291 additions and 254 deletions.
3 changes: 3 additions & 0 deletions .github/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Security Policy

To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
7 changes: 4 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ jobs:
fail-fast: false
matrix:
node-version:
- 16
- 20
- 18
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm install
Expand Down
9 changes: 2 additions & 7 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ In addition to the regular tests via `npm test`, contributors should also ensure
Please sign up for a free GA web-tracking account, then run below script using your tracking code:

```js
const Insight = require('lib/insight.js');
import Insight from 'lib/insight.js';

const insight = new Insight({
trackingCode: 'UA-00000000-0', // replace with your test GA tracking code
trackingCode: 'UA-00000000-0', // Replace with your test GA tracking code.
packageName: 'test app',
packageVersion: '0.0.1'
});
Expand All @@ -21,8 +21,3 @@ insight.track('hello', 'sindre');
Then visit GA's Real Time dashboard and ensure data is showing up:

![analytics screenshot](screenshot-real-time.png)


## Other Guidelines

Please see Yeoman's [contributing docs](https://github.com/yeoman/yeoman/blob/master/contributing.md).
103 changes: 57 additions & 46 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,57 @@
'use strict';
const path = require('path');
const childProcess = require('child_process');
const osName = require('os-name');
const Conf = require('conf');
const chalk = require('chalk');
const debounce = require('lodash.debounce');
const inquirer = require('inquirer');
const uuid = require('uuid');
const providers = require('./providers.js');
import process from 'node:process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import childProcess from 'node:child_process';
import {randomUUID} from 'node:crypto';
import osName from 'os-name';
import Conf from 'conf';
import chalk from 'chalk';
import debounce from 'lodash.debounce';
import inquirer from 'inquirer';
import providers from './providers.js';

const DEBOUNCE_MS = 100;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

class Insight {
constructor(options) {
options = options || {};
options.pkg = options.pkg || {};
#queue = {};
#permissionTimeout = 30;
#debouncedSend;

constructor(options = {}) {
const {pkg: package_ = {}} = options;

// Deprecated options
// TODO: Remove these at some point in the future
if (options.packageName) {
options.pkg.name = options.packageName;
package_.name = options.packageName;
}

if (options.packageVersion) {
options.pkg.version = options.packageVersion;
package_.version = options.packageVersion;
}

if (!options.trackingCode || !options.pkg.name) {
if (!options.trackingCode || !package_.name) {
throw new Error('trackingCode and pkg.name required');
}

this.trackingCode = options.trackingCode;
this.trackingProvider = options.trackingProvider || 'google';
this.packageName = options.pkg.name;
this.packageVersion = options.pkg.version || 'undefined';
this.trackingProvider = options.trackingProvider ?? 'google';
this.packageName = package_.name;
this.packageVersion = package_.version ?? 'undefined';
this.os = osName();
this.nodeVersion = process.version;
this.appVersion = this.packageVersion;
this.config = options.config || new Conf({
this.config = options.config ?? new Conf({
projectName: package_.name,
configName: `insight-${this.packageName}`,
defaults: {
clientId: options.clientId || Math.floor(Date.now() * Math.random()),
clientId: options.clientId ?? Math.floor(Date.now() * Math.random()),
},
});
this._queue = {};
this._permissionTimeout = 30;
this._debouncedSend = debounce(this._send, DEBOUNCE_MS, {leading: true});

this.#debouncedSend = debounce(this.#send.bind(this), DEBOUNCE_MS, {leading: true});
}

get optOut() {
Expand All @@ -64,22 +70,23 @@ class Insight {
this.config.set('clientId', value);
}

_save() {
#save() {
setImmediate(() => {
this._debouncedSend();
this.#debouncedSend();
});
}

_send() {
const pending = Object.keys(this._queue).length;
#send() {
const pending = Object.keys(this.#queue).length;
if (pending === 0) {
return;
}

this._fork(this._getPayload());
this._queue = {};
this._fork(this.#getPayload());
this.#queue = {};
}

// For testing.
_fork(payload) {
// Extracted to a method so it can be easily mocked
const cp = childProcess.fork(path.join(__dirname, 'push.js'), {silent: true});
Expand All @@ -88,33 +95,36 @@ class Insight {
cp.disconnect();
}

_getPayload() {
#getPayload() {
return {
queue: {...this._queue},
queue: {...this.#queue},
packageName: this.packageName,
packageVersion: this.packageVersion,
trackingCode: this.trackingCode,
trackingProvider: this.trackingProvider,
};
}

_getRequestObj(...args) {
return providers[this.trackingProvider].apply(this, args);
// For testing.
_getRequestObj(...arguments_) {
return providers[this.trackingProvider].apply(this, arguments_);
}

track(...args) {
track(...arguments_) {
if (this.optOut) {
return;
}

const path = '/' + args.map(element => String(element).trim().replace(/ /, '-')).join('/');
const path = '/' + arguments_.map(element =>
String(element).trim().replace(/ /, '-'),
).join('/');

// Timestamp isn't unique enough since it can end up with duplicate entries
this._queue[`${Date.now()} ${uuid.v4()}`] = {
this.#queue[`${Date.now()} ${randomUUID()}`] = {
path,
type: 'pageview',
};
this._save();
this.#save();
}

trackEvent(options) {
Expand All @@ -126,32 +136,33 @@ class Insight {
throw new Error('Event tracking is supported only for Google Analytics');
}

if (!options || !options.category || !options.action) {
if (!options?.category || !options?.action) {
throw new Error('`category` and `action` required');
}

// Timestamp isn't unique enough since it can end up with duplicate entries
this._queue[`${Date.now()} ${uuid.v4()}`] = {
this.#queue[`${Date.now()} ${randomUUID()}`] = {
category: options.category,
action: options.action,
label: options.label,
value: options.value,
type: 'event',
};
this._save();

this.#save();
}

askPermission(message) {
async askPermission(message) {
const defaultMessage = `May ${chalk.cyan(this.packageName)} anonymously report usage statistics to improve the tool over time?`;

if (!process.stdout.isTTY || process.argv.includes('--no-insight') || process.env.CI) {
return Promise.resolve();
return;
}

const prompt = inquirer.prompt({
type: 'confirm',
name: 'optIn',
message: message || defaultMessage,
message: message ?? defaultMessage,
default: true,
});

Expand All @@ -165,7 +176,7 @@ class Insight {
// Automatically opt out
this.optOut = true;
resolve(false);
}, this._permissionTimeout * 1000);
}, this.#permissionTimeout * 1000);
});

const promise = (async () => {
Expand All @@ -183,4 +194,4 @@ class Insight {
}
}

module.exports = Insight;
export default Insight;
73 changes: 35 additions & 38 deletions lib/providers.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
'use strict';
const qs = require('querystring');

/**
* Tracking providers
*
* Each provider is a function(id, path) that should return
* options object for request() call. It will be called bound
* to Insight instance object.
*/

module.exports = {
Tracking providers.
Each provider is a function(id, path) that should return options object for ky() call. It will be called bound to Insight instance object.
*/

const payload = {
// Google Analytics — https://www.google.com/analytics/
google(id, payload) {
const now = Date.now();

const _qs = {
const queryParameters = new URLSearchParams({
// GA Measurement Protocol API version
v: 1,
v: '1',

// Hit type
t: payload.type,

// Anonymize IP
aip: 1,
aip: '1',

tid: this.trackingCode,

Expand All @@ -42,63 +37,65 @@ module.exports = {

// Cache busting, need to be last param sent
z: now,
};
});

// Set payload data based on the tracking type
if (payload.type === 'event') {
_qs.ec = payload.category;
_qs.ea = payload.action;
queryParameters.set('ec', payload.category); // Event category
queryParameters.set('ea', payload.action); // Event action

if (payload.label) {
_qs.el = payload.label;
queryParameters.set('el', payload.label); // Event label
}

if (payload.value) {
_qs.ev = payload.value;
queryParameters.set('ev', payload.value); // Event value
}
} else {
_qs.dp = payload.path;
queryParameters.set('dp', payload.path); // Document path
}

return {
url: 'https://ssl.google-analytics.com/collect',
method: 'POST',
// GA docs recommends body payload via POST instead of querystring via GET
body: qs.stringify(_qs),
// GA docs recommend body payload via POST instead of querystring via GET
body: queryParameters.toString(),
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
};
},

// Yandex.Metrica - https://metrica.yandex.com
yandex(id, payload) {
const request = require('request');

const ts = new Date(Number.parseInt(id, 10))
.toISOString()
.replace(/[-:T]/g, '')
.replace(/\..*$/, '');
.replaceAll(/[-:T]/g, '') // Remove `-`, `:`, and `T`
.replace(/\..*$/, ''); // Remove milliseconds

const {path} = payload;
const qs = {
wmode: 3,
ut: 'noindex',

// Query parameters for Yandex.Metrica
const queryParameters = new URLSearchParams({
wmode: '3', // Web mode
ut: 'noindex', // User type
'page-url': `http://${this.packageName}.insight${path}?version=${this.packageVersion}`,
'browser-info': `i:${ts}:z:0:t:${path}`,
// Cache busting
rn: Date.now(),
};
});

const url = `https://mc.yandex.ru/watch/${this.trackingCode}`;

// Set custom cookie using tough-cookie
const _jar = request.jar();
const cookieString = `name=yandexuid; value=${this.clientId}; path=/;`;
const cookie = request.cookie(cookieString);
_jar.setCookie(cookie, url);

return {
url,
method: 'GET',
qs,
jar: _jar,
searchParams: queryParameters,
headers: {
Cookie: `yandexuid=${this.clientId}`,
},
};
},
};

export default payload;
Loading

0 comments on commit 35487b2

Please sign in to comment.