verdaccio-stats 0.1.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.
package/lib/index.mjs ADDED
@@ -0,0 +1,679 @@
1
+ import z from 'zod';
2
+ import dayjs, { extend } from 'dayjs';
3
+ import advancedFormat from 'dayjs/plugin/advancedFormat';
4
+ import isoWeek from 'dayjs/plugin/isoWeek';
5
+ import weekOfYear from 'dayjs/plugin/weekOfYear';
6
+ import weekYear from 'dayjs/plugin/weekYear';
7
+ import path from 'node:path';
8
+ import { Model, DataTypes, Sequelize } from 'sequelize';
9
+ import { tarballUtils } from '@verdaccio/core';
10
+ import { PACKAGE_API_ENDPOINTS } from '@verdaccio/middleware';
11
+ import buildDebug from 'debug';
12
+ import { Umzug, SequelizeStorage } from 'umzug';
13
+
14
+ var name = "verdaccio-stats";
15
+ var version = "0.1.0";
16
+
17
+ const plugin = {
18
+ name,
19
+ version,
20
+ };
21
+ const pluginKey = name.replace("verdaccio-", "");
22
+ const UNIVERSE_PACKAGE_NAME = "**";
23
+ const UNIVERSE_PACKAGE_VERSION = "*";
24
+ const PERIOD_TYPES = ["overall", "daily", "monthly", "weekly", "yearly"];
25
+ const PERIOD_VALUE_TOTAL = "total";
26
+ const DEFAULT_SQLITE_FILE = "stats.db";
27
+ const API_BASE_PATH = "/-/verdaccio/stats";
28
+
29
+ function noop() {
30
+ /* noop */
31
+ }
32
+ const dummyLogger = {
33
+ child: () => dummyLogger,
34
+ debug: noop,
35
+ error: noop,
36
+ http: noop,
37
+ info: noop,
38
+ trace: noop,
39
+ warn: noop,
40
+ };
41
+ let logger = dummyLogger;
42
+ function setLogger(l) {
43
+ if (!l)
44
+ return;
45
+ logger = l.child({ plugin: { name: plugin.name } });
46
+ logger.info(plugin, "plugin loading: @{name}@@{version}");
47
+ }
48
+
49
+ extend(advancedFormat);
50
+ extend(weekYear);
51
+ extend(weekOfYear);
52
+ extend(isoWeek);
53
+ /**
54
+ * Get the period value for the given period type.
55
+ *
56
+ * @param periodType {PeriodType} - The period type.
57
+ * @param date {Dayjs} - The date to get the period value for.
58
+ * @param isoWeek {boolean} - Whether to use ISO week format.
59
+ * @returns {PeriodValue} The period value.
60
+ */
61
+ function getPeriodValue(periodType, date, isoWeek) {
62
+ switch (periodType) {
63
+ case "daily": {
64
+ return dayjs(date).format("YYYY-MM-DD");
65
+ }
66
+ case "monthly": {
67
+ return dayjs(date).format("YYYY-MM");
68
+ }
69
+ case "overall": {
70
+ return PERIOD_VALUE_TOTAL;
71
+ }
72
+ case "weekly": {
73
+ return dayjs(date).format(isoWeek ? "GGGG-[W]WW" : "gggg-[W]ww");
74
+ }
75
+ case "yearly": {
76
+ return dayjs(date).format("YYYY");
77
+ }
78
+ default: {
79
+ throw new Error(`Unknown period type: ${String(periodType)}`);
80
+ }
81
+ }
82
+ }
83
+ /**
84
+ * Check if the status code is a success status code.
85
+ *
86
+ * @param statusCode {number} - The status code.
87
+ * @returns {boolean} Whether the status code is a success status code.
88
+ */
89
+ function isSuccessStatus(statusCode) {
90
+ return (statusCode >= 200 && statusCode < 300) || statusCode === 304;
91
+ }
92
+ /**
93
+ * Get the absolute path of a file path.
94
+ *
95
+ * @param configPath {string} - The path to the config file.
96
+ * @param targetPath {string} - The path to the files.
97
+ * @returns {string} The absolute path of the file.
98
+ */
99
+ function normalizeFilePath(configPath, targetPath) {
100
+ return path.isAbsolute(targetPath) ? targetPath : path.normalize(path.join(path.dirname(configPath), targetPath));
101
+ }
102
+ /**
103
+ * Wraps the given URL path for Verdaccio stats.
104
+ *
105
+ * @param urlPath {string} - The path to be wrapped.
106
+ * @returns {string} The wrapped path.
107
+ */
108
+ function wrapPath(urlPath) {
109
+ return `${API_BASE_PATH}${urlPath}`;
110
+ }
111
+
112
+ const statsConfig = z.object({
113
+ file: z.string().optional(),
114
+ "iso-week": z.boolean().optional(),
115
+ "count-downloads": z.boolean().optional(),
116
+ "count-manifest-views": z.boolean().optional(),
117
+ });
118
+ class ParsedPluginConfig {
119
+ config;
120
+ verdaccioConfig;
121
+ get configPath() {
122
+ return this.verdaccioConfig.configPath ?? this.verdaccioConfig.self_path;
123
+ }
124
+ get countDownloads() {
125
+ return this.config["count-downloads"] ?? true;
126
+ }
127
+ get countManifestViews() {
128
+ return this.config["count-manifest-views"] ?? true;
129
+ }
130
+ get file() {
131
+ return normalizeFilePath(this.configPath, this.config.file ?? DEFAULT_SQLITE_FILE);
132
+ }
133
+ get isoWeek() {
134
+ return this.config["iso-week"] ?? false;
135
+ }
136
+ get logo() {
137
+ return this.verdaccioConfig.web?.logo;
138
+ }
139
+ get title() {
140
+ return this.verdaccioConfig.web?.title ?? "Verdaccio Stats";
141
+ }
142
+ constructor(config, verdaccioConfig) {
143
+ this.config = config;
144
+ this.verdaccioConfig = verdaccioConfig;
145
+ try {
146
+ statsConfig.parse(config);
147
+ }
148
+ catch (err) {
149
+ const zodError = err;
150
+ logger.error({ errors: zodError.flatten().fieldErrors }, "Invalid config, @{errors}");
151
+ process.exit(1);
152
+ }
153
+ }
154
+ }
155
+
156
+ class DownloadStats extends Model {
157
+ }
158
+ class ManifestViewStats extends Model {
159
+ }
160
+ class Package extends Model {
161
+ }
162
+
163
+ const rootPath = wrapPath("/admin");
164
+ const defaultActions = {
165
+ new: { isAccessible: false },
166
+ edit: { isAccessible: false },
167
+ delete: { isAccessible: false },
168
+ bulkDelete: { isAccessible: false },
169
+ };
170
+ process.env.ADMIN_JS_SKIP_BUNDLE = "true";
171
+ /**
172
+ * Add Admin UI to the application.
173
+ */
174
+ class AdminUI {
175
+ adminRouter = null;
176
+ config;
177
+ constructor(config) {
178
+ this.config = config;
179
+ void this.create().then((router) => {
180
+ this.adminRouter = router;
181
+ });
182
+ }
183
+ static populatePackageIdListProperties(response) {
184
+ for (const record of response.records) {
185
+ if (record.populated.packageId?.params) {
186
+ const params = record.populated.packageId.params;
187
+ record.populated.packageId.title = `${params.name}@${params.version}`;
188
+ }
189
+ }
190
+ return response;
191
+ }
192
+ static populatePackageIdSearchProperties(response) {
193
+ for (const record of response.records) {
194
+ if (record.populated.packageId?.params) {
195
+ const params = record.populated.packageId.params;
196
+ record.populated.packageId.title = `${params.name}@${params.version}`;
197
+ }
198
+ }
199
+ return response;
200
+ }
201
+ static populatePackageIdShowProperties(response) {
202
+ if (response.record.populated.packageId?.params) {
203
+ const params = response.record.populated.packageId.params;
204
+ response.record.populated.packageId.title = `${params.name}@${params.version}`;
205
+ }
206
+ return response;
207
+ }
208
+ register_middlewares(app) {
209
+ app.use(rootPath, (req, res, next) => {
210
+ if (this.adminRouter) {
211
+ return this.adminRouter(req, res, next);
212
+ }
213
+ else {
214
+ res.status(503).send("Admin UI is not ready yet.");
215
+ }
216
+ });
217
+ }
218
+ async create() {
219
+ const [AdminJS, AdminJSExpress, AdminJSSequelize] = await Promise.all([
220
+ import('adminjs').then((mod) => mod.default),
221
+ import('@adminjs/express').then((mod) => mod.default),
222
+ import('@adminjs/sequelize').then((mod) => mod.default),
223
+ ]);
224
+ AdminJS.registerAdapter({
225
+ Resource: AdminJSSequelize.Resource,
226
+ Database: AdminJSSequelize.Database,
227
+ });
228
+ const admin = new AdminJS({
229
+ resources: [
230
+ {
231
+ resource: Package,
232
+ options: {
233
+ actions: { ...defaultActions },
234
+ listProperties: ["id", "name", "version", "createdAt"],
235
+ showProperties: ["id", "name", "version", "createdAt"],
236
+ filterProperties: ["name", "version"],
237
+ sort: { sortBy: "createdAt", direction: "desc" },
238
+ },
239
+ },
240
+ {
241
+ resource: DownloadStats,
242
+ options: {
243
+ actions: {
244
+ ...defaultActions,
245
+ show: { after: AdminUI.populatePackageIdShowProperties },
246
+ list: { after: AdminUI.populatePackageIdListProperties },
247
+ search: { after: AdminUI.populatePackageIdSearchProperties },
248
+ },
249
+ properties: {
250
+ periodType: {
251
+ availableValues: PERIOD_TYPES.map((type) => ({ value: type, label: type })),
252
+ },
253
+ },
254
+ listProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
255
+ showProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
256
+ filterProperties: ["packageId", "periodType", "periodValue", "createdAt", "updatedAt"],
257
+ sort: { sortBy: "updatedAt", direction: "desc" },
258
+ },
259
+ },
260
+ {
261
+ resource: ManifestViewStats,
262
+ options: {
263
+ actions: {
264
+ ...defaultActions,
265
+ show: { after: AdminUI.populatePackageIdShowProperties },
266
+ list: { after: AdminUI.populatePackageIdListProperties },
267
+ search: { after: AdminUI.populatePackageIdSearchProperties },
268
+ },
269
+ properties: {
270
+ periodType: {
271
+ availableValues: PERIOD_TYPES.map((type) => ({ value: type, label: type })),
272
+ },
273
+ },
274
+ listProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
275
+ showProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
276
+ filterProperties: ["packageId", "periodType", "periodValue", "createdAt", "updatedAt"],
277
+ sort: { sortBy: "updatedAt", direction: "desc" },
278
+ },
279
+ },
280
+ ],
281
+ rootPath: rootPath,
282
+ branding: {
283
+ companyName: this.config.title,
284
+ logo: this.config.logo,
285
+ },
286
+ env: {
287
+ ADMIN_JS_SKIP_BUNDLE: "true",
288
+ },
289
+ });
290
+ return AdminJSExpress.buildRouter(admin);
291
+ }
292
+ }
293
+
294
+ class Hooks {
295
+ config;
296
+ db = null;
297
+ constructor(config) {
298
+ this.config = config;
299
+ }
300
+ packageManifestHandler = (req, res, next) => {
301
+ const db = this.db;
302
+ if (!db) {
303
+ logger.warn("DB instance is not ready; skipping manifest stats");
304
+ return next();
305
+ }
306
+ const packageName = req.params.package;
307
+ const version = req.params.version;
308
+ if (packageName === "favicon.ico") {
309
+ logger.debug("Skipping manifest stats for favicon request");
310
+ return next();
311
+ }
312
+ res.once("finish", () => {
313
+ if (!isSuccessStatus(res.statusCode)) {
314
+ logger.debug("Skipping manifest stats for non-2xx response");
315
+ return;
316
+ }
317
+ if (!packageName) {
318
+ logger.warn("Unexpected missing package name in request");
319
+ return;
320
+ }
321
+ logger.debug({ packageName, version }, "Adding manifest view stats for package @{packageName} version @{version}");
322
+ db.addManifestViewCount(packageName, version).catch((err) => {
323
+ logger.error({ err }, "Failed to add manifest count; @{err}");
324
+ });
325
+ });
326
+ return next();
327
+ };
328
+ register_middlewares(app) {
329
+ if (this.config.countDownloads) {
330
+ app.get(PACKAGE_API_ENDPOINTS.get_package_tarball, this.tarballDownloadHandler);
331
+ }
332
+ if (this.config.countManifestViews) {
333
+ app.get(PACKAGE_API_ENDPOINTS.get_package_by_version, this.packageManifestHandler);
334
+ }
335
+ }
336
+ setDatabase(db) {
337
+ this.db = db;
338
+ }
339
+ tarballDownloadHandler = (req, res, next) => {
340
+ const db = this.db;
341
+ if (!db) {
342
+ logger.warn("DB instance is not ready; skipping download stats");
343
+ return next();
344
+ }
345
+ // react
346
+ const packageName = req.params.package;
347
+ // react-18.0.0.tgz
348
+ const filename = req.params.filename;
349
+ res.once("finish", () => {
350
+ if (!isSuccessStatus(res.statusCode)) {
351
+ logger.debug("Skipping download stats for non-2xx response");
352
+ return;
353
+ }
354
+ // react-18.0.0.tgz -> 18.0.0
355
+ const version = tarballUtils.getVersionFromTarball(filename);
356
+ if (!packageName || !version) {
357
+ logger.warn("Unexpected missing package name or filename in request");
358
+ return;
359
+ }
360
+ logger.debug({ packageName, version }, "Adding download stats for package @{packageName} version @{version}");
361
+ db.addDownloadCount(packageName, version).catch((err) => {
362
+ logger.error({ err }, "Failed to add download count; @{err}");
363
+ });
364
+ });
365
+ return next();
366
+ };
367
+ }
368
+
369
+ /**
370
+ * NOTE: This middleware is not implemented yet.
371
+ */
372
+ class Stats {
373
+ config;
374
+ db = null;
375
+ constructor(config) {
376
+ this.config = config;
377
+ }
378
+ register_middlewares(app) {
379
+ app.get(wrapPath("/downloads/latest"), this.notImplementedHandler);
380
+ app.get(wrapPath("/downloads/total"), this.notImplementedHandler);
381
+ app.get(wrapPath("/downloads/popular/:count?"), this.notImplementedHandler);
382
+ app.get(wrapPath("/downloads/package/:package/:version?"), this.notImplementedHandler);
383
+ app.get(wrapPath("/manifest/latest"), this.notImplementedHandler);
384
+ app.get(wrapPath("/manifest/total"), this.notImplementedHandler);
385
+ app.get(wrapPath("/manifest/popular/:count?"), this.notImplementedHandler);
386
+ app.get(wrapPath("/manifest/views/package/:package/:version?"), this.notImplementedHandler);
387
+ }
388
+ setDatabase(db) {
389
+ this.db = db;
390
+ }
391
+ checkDatabase(db, res) {
392
+ if (!db) {
393
+ res.sendStatus(500).send("Database not set");
394
+ return false;
395
+ }
396
+ return true;
397
+ }
398
+ notImplementedHandler = (req, res) => {
399
+ if (!this.checkDatabase(this.db, res))
400
+ return;
401
+ res.sendStatus(501).send("Not implemented");
402
+ };
403
+ }
404
+
405
+ const debug = buildDebug(`verdaccio:plugin:${pluginKey}`);
406
+ function getUmzugLogger() {
407
+ return {
408
+ debug: (msg) => debug("umzug:debug: %j", msg),
409
+ error: (msg) => debug("umzug:error: %j", msg),
410
+ info: (msg) => debug("umzug:info: %j", msg),
411
+ warn: (msg) => debug("umzug:warn: %j", msg),
412
+ };
413
+ }
414
+
415
+ const migrations = [
416
+ {
417
+ name: "000-initial",
418
+ up: async ({ context: sequelize }) => {
419
+ const queryInterface = sequelize.getQueryInterface();
420
+ await Promise.all([
421
+ queryInterface.createTable("packages", {
422
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
423
+ name: { allowNull: false, type: DataTypes.STRING(100) },
424
+ version: { allowNull: false, type: DataTypes.STRING(50) },
425
+ created_at: { allowNull: false, type: DataTypes.DATE },
426
+ updated_at: { allowNull: false, type: DataTypes.DATE },
427
+ }),
428
+ queryInterface.createTable("download_stats", {
429
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
430
+ package_id: { allowNull: false, type: DataTypes.INTEGER },
431
+ period_type: { allowNull: false, type: DataTypes.STRING(20) },
432
+ period_value: { allowNull: false, type: DataTypes.STRING(20) },
433
+ count: { allowNull: false, type: DataTypes.BIGINT, defaultValue: 0 },
434
+ created_at: { allowNull: false, type: DataTypes.DATE },
435
+ updated_at: { allowNull: false, type: DataTypes.DATE },
436
+ }),
437
+ queryInterface.createTable("manifest_view_stats", {
438
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
439
+ package_id: { allowNull: false, type: DataTypes.INTEGER },
440
+ period_type: { allowNull: false, type: DataTypes.STRING(20) },
441
+ period_value: { allowNull: false, type: DataTypes.STRING(20) },
442
+ count: { allowNull: false, type: DataTypes.BIGINT, defaultValue: 0 },
443
+ created_at: { allowNull: false, type: DataTypes.DATE },
444
+ updated_at: { allowNull: false, type: DataTypes.DATE },
445
+ }),
446
+ ]);
447
+ await Promise.all([
448
+ queryInterface.addIndex("packages", ["name", "version"], {
449
+ unique: true,
450
+ name: "packages_index",
451
+ }),
452
+ queryInterface.addIndex("download_stats", ["package_id", "period_type", "period_value"], {
453
+ name: "download_stats_index",
454
+ }),
455
+ queryInterface.addIndex("manifest_view_stats", ["package_id", "period_type", "period_value"], {
456
+ name: "manifest_view_stats_index",
457
+ }),
458
+ ]);
459
+ return queryInterface.bulkInsert("packages", [
460
+ {
461
+ name: UNIVERSE_PACKAGE_NAME,
462
+ version: UNIVERSE_PACKAGE_VERSION,
463
+ created_at: new Date(),
464
+ updated_at: new Date(),
465
+ },
466
+ ]);
467
+ },
468
+ down: async ({ context: Sequelize }) => {
469
+ const queryInterface = Sequelize.getQueryInterface();
470
+ await Promise.all([
471
+ queryInterface.removeIndex("packages", "packages_index"),
472
+ queryInterface.removeIndex("download_stats", "download_stats_index"),
473
+ queryInterface.removeIndex("manifest_view_stats", "manifest_view_stats_index"),
474
+ ]);
475
+ return Promise.all(["packages", "download_stats", "manifest_view_stats"].map((table) => queryInterface.dropTable(table)));
476
+ },
477
+ },
478
+ ];
479
+
480
+ class Database {
481
+ config;
482
+ sequelize;
483
+ umzug;
484
+ constructor(config) {
485
+ const sequelize = new Sequelize({
486
+ dialect: "sqlite",
487
+ storage: config.file,
488
+ logging: (sql) => buildDebug(sql),
489
+ });
490
+ const umzug = new Umzug({
491
+ context: () => sequelize,
492
+ migrations: migrations,
493
+ storage: new SequelizeStorage({ sequelize, modelName: "migration_meta" }),
494
+ logger: getUmzugLogger(),
495
+ });
496
+ this.config = config;
497
+ this.sequelize = sequelize;
498
+ this.umzug = umzug;
499
+ }
500
+ static async create(config) {
501
+ const db = new Database(config);
502
+ await db.migrate();
503
+ await db.init();
504
+ return db;
505
+ }
506
+ async addDownloadCount(packageName, version) {
507
+ const t = await this.sequelize.transaction();
508
+ try {
509
+ await Promise.all([
510
+ this.addTotalDownloadCount(t),
511
+ this.addPackageDownloadCount(packageName, UNIVERSE_PACKAGE_VERSION, t),
512
+ this.addPackageDownloadCount(packageName, version, t),
513
+ ]);
514
+ await t.commit();
515
+ }
516
+ catch (err) {
517
+ await t.rollback();
518
+ throw err;
519
+ }
520
+ }
521
+ async addManifestViewCount(packageName, version) {
522
+ const t = await this.sequelize.transaction();
523
+ try {
524
+ await Promise.all([
525
+ this.addTotalManifestViewCount(t),
526
+ this.addPackageManifestViewCount(packageName, UNIVERSE_PACKAGE_VERSION, t),
527
+ version && this.addPackageManifestViewCount(packageName, version, t),
528
+ ].filter(Boolean));
529
+ await t.commit();
530
+ }
531
+ catch (err) {
532
+ await t.rollback();
533
+ throw err;
534
+ }
535
+ }
536
+ migrate() {
537
+ return this.umzug.up();
538
+ }
539
+ rollback() {
540
+ return this.umzug.down();
541
+ }
542
+ async addDownloadForAllPeriod(pkg, transaction) {
543
+ return Promise.all(PERIOD_TYPES.map(async (periodType) => {
544
+ const [downloadStats] = await DownloadStats.findOrCreate({
545
+ where: {
546
+ packageId: pkg.id,
547
+ periodType: periodType,
548
+ periodValue: getPeriodValue(periodType, undefined, this.config.isoWeek),
549
+ },
550
+ transaction,
551
+ });
552
+ return downloadStats.increment("count", { by: 1, transaction });
553
+ }));
554
+ }
555
+ async addManifestViewForAllPeriod(pkg, transaction) {
556
+ return Promise.all(PERIOD_TYPES.map(async (periodType) => {
557
+ const [manifestViewStats] = await ManifestViewStats.findOrCreate({
558
+ where: {
559
+ packageId: pkg.id,
560
+ periodType: periodType,
561
+ periodValue: getPeriodValue(periodType, undefined, this.config.isoWeek),
562
+ },
563
+ transaction,
564
+ });
565
+ return manifestViewStats.increment("count", { by: 1, transaction });
566
+ }));
567
+ }
568
+ async addPackageDownloadCount(packageName, version, transaction) {
569
+ const [pkg] = await Package.findOrCreate({ where: { name: packageName, version }, transaction });
570
+ if (!pkg) {
571
+ throw new Error("Package not found");
572
+ }
573
+ return this.addDownloadForAllPeriod(pkg, transaction);
574
+ }
575
+ async addPackageManifestViewCount(packageName, version, transaction) {
576
+ const [pkg] = await Package.findOrCreate({ where: { name: packageName, version }, transaction });
577
+ if (!pkg) {
578
+ throw new Error("Package not found");
579
+ }
580
+ return this.addManifestViewForAllPeriod(pkg, transaction);
581
+ }
582
+ async addTotalDownloadCount(transaction) {
583
+ const universePkg = await Package.findOne({
584
+ where: { name: UNIVERSE_PACKAGE_NAME, version: UNIVERSE_PACKAGE_VERSION },
585
+ transaction,
586
+ });
587
+ if (!universePkg) {
588
+ throw new Error("Universe package not found");
589
+ }
590
+ return this.addDownloadForAllPeriod(universePkg, transaction);
591
+ }
592
+ async addTotalManifestViewCount(transaction) {
593
+ const universePkg = await Package.findOne({
594
+ where: { name: UNIVERSE_PACKAGE_NAME, version: UNIVERSE_PACKAGE_VERSION },
595
+ transaction,
596
+ });
597
+ if (!universePkg) {
598
+ throw new Error("Universe package not found");
599
+ }
600
+ return this.addManifestViewForAllPeriod(universePkg, transaction);
601
+ }
602
+ async init() {
603
+ await Promise.all([
604
+ Package.init({
605
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
606
+ name: { allowNull: false, type: DataTypes.STRING(100) },
607
+ version: { allowNull: false, type: DataTypes.STRING(50) },
608
+ }, { sequelize: this.sequelize, tableName: "packages", underscored: true }),
609
+ DownloadStats.init({
610
+ count: { allowNull: false, type: DataTypes.BIGINT, defaultValue: 0 },
611
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
612
+ packageId: { allowNull: false, type: DataTypes.INTEGER, references: { model: Package, key: "id" } },
613
+ periodType: { allowNull: false, type: DataTypes.STRING(20) },
614
+ periodValue: { allowNull: false, type: DataTypes.STRING(20) },
615
+ }, { sequelize: this.sequelize, tableName: "download_stats", underscored: true }),
616
+ ManifestViewStats.init({
617
+ count: { allowNull: false, type: DataTypes.BIGINT, defaultValue: 0 },
618
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: DataTypes.INTEGER },
619
+ packageId: { allowNull: false, type: DataTypes.INTEGER, references: { model: Package, key: "id" } },
620
+ periodType: { allowNull: false, type: DataTypes.STRING(20) },
621
+ periodValue: { allowNull: false, type: DataTypes.STRING(20) },
622
+ }, { sequelize: this.sequelize, tableName: "manifest_view_stats", underscored: true }),
623
+ ]);
624
+ Package.hasMany(DownloadStats, {
625
+ sourceKey: "id",
626
+ foreignKey: "packageId",
627
+ });
628
+ Package.hasMany(ManifestViewStats, {
629
+ sourceKey: "id",
630
+ foreignKey: "packageId",
631
+ });
632
+ DownloadStats.belongsTo(Package, {
633
+ targetKey: "id",
634
+ foreignKey: "packageId",
635
+ as: "package",
636
+ });
637
+ ManifestViewStats.belongsTo(Package, {
638
+ targetKey: "id",
639
+ foreignKey: "packageId",
640
+ as: "package",
641
+ });
642
+ }
643
+ }
644
+
645
+ class Plugin {
646
+ config;
647
+ options;
648
+ get version() {
649
+ return +plugin.version;
650
+ }
651
+ parsedConfig;
652
+ constructor(config, options) {
653
+ this.config = config;
654
+ this.options = options;
655
+ setLogger(options.logger);
656
+ this.parsedConfig = new ParsedPluginConfig(config, options.config);
657
+ }
658
+ getVersion() {
659
+ return this.version;
660
+ }
661
+ register_middlewares(app) {
662
+ const db = Database.create(this.parsedConfig);
663
+ const hooks = new Hooks(this.parsedConfig);
664
+ const stats = new Stats(this.parsedConfig);
665
+ const adminUI = new AdminUI(this.parsedConfig);
666
+ db.then((db) => {
667
+ hooks.setDatabase(db);
668
+ stats.setDatabase(db);
669
+ }).catch((err) => {
670
+ logger.error({ err }, "Failed to initialize database; @{err}");
671
+ process.exit(1);
672
+ });
673
+ for (const middleware of [hooks, stats, adminUI]) {
674
+ middleware.register_middlewares(app);
675
+ }
676
+ }
677
+ }
678
+
679
+ export { Plugin as default };
@@ -0,0 +1,4 @@
1
+ import type { Logger } from "@verdaccio/types";
2
+ declare let logger: Logger;
3
+ export declare function setLogger(l?: Logger): void;
4
+ export default logger;
@@ -0,0 +1,17 @@
1
+ import type { ListActionResponse, RecordActionResponse, SearchActionResponse } from "adminjs";
2
+ import type { Application } from "express";
3
+ import type { ConfigHolder } from "../config";
4
+ import type { PluginMiddleware } from "../types";
5
+ /**
6
+ * Add Admin UI to the application.
7
+ */
8
+ export declare class AdminUI implements PluginMiddleware {
9
+ private adminRouter;
10
+ private config;
11
+ constructor(config: ConfigHolder);
12
+ static populatePackageIdListProperties(this: void, response: ListActionResponse): ListActionResponse;
13
+ static populatePackageIdSearchProperties(this: void, response: SearchActionResponse): SearchActionResponse;
14
+ static populatePackageIdShowProperties(this: void, response: RecordActionResponse): RecordActionResponse;
15
+ register_middlewares(app: Application): void;
16
+ private create;
17
+ }