• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 12018572686

25 Nov 2024 08:51PM UTC coverage: 82.056% (-0.03%) from 82.089%
12018572686

Pull #657

github

web-flow
Merge 3742dbfd0 into cbb61457b
Pull Request #657: SQLite Key-value store to replace Redis

840 of 1010 branches covered (83.17%)

Branch coverage included in aggregate %.

26 of 37 new or added lines in 2 files covered. (70.27%)

109 existing lines in 4 files now uncovered.

9115 of 11122 relevant lines covered (81.95%)

89.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.41
/src/Uwave.js
1
import EventEmitter from 'node:events';
1✔
2
import { promisify } from 'node:util';
1✔
3
import Redis from 'ioredis';
1✔
4
import avvio from 'avvio';
1✔
5
import { pino } from 'pino';
1✔
6
import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely';
1✔
7
import httpApi, { errorHandling } from './HttpApi.js';
1✔
8
import SocketServer from './SocketServer.js';
1✔
9
import { Source } from './Source.js';
1✔
10
import { i18n } from './locale.js';
1✔
11
import configStore from './plugins/configStore.js';
1✔
12
import booth from './plugins/booth.js';
1✔
13
import chat from './plugins/chat.js';
1✔
14
import motd from './plugins/motd.js';
1✔
15
import playlists from './plugins/playlists.js';
1✔
16
import users from './plugins/users.js';
1✔
17
import bans from './plugins/bans.js';
1✔
18
import history from './plugins/history.js';
1✔
19
import acl from './plugins/acl.js';
1✔
20
import waitlist from './plugins/waitlist.js';
1✔
21
import passport from './plugins/passport.js';
1✔
22
import migrations from './plugins/migrations.js';
1✔
23
import { SqliteDateColumnsPlugin, connect as connectSqlite, jsonb } from './utils/sqlite.js';
1✔
24

1✔
25
const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave';
1✔
26
const DEFAULT_REDIS_URL = 'redis://localhost:6379';
1✔
27

1✔
28
class UwCamelCasePlugin extends CamelCasePlugin {
1✔
29
  /**
1✔
30
   * @param {string} str
1✔
31
   * @override
1✔
32
   * @protected
1✔
33
   */
1✔
34
  camelCase(str) {
1✔
35
    return super.camelCase(str).replace(/Id$/, 'ID');
22,818✔
36
  }
22,818✔
37
}
1✔
38

1✔
39
/**
1✔
40
 * @typedef {import('./Source.js').SourcePlugin} SourcePlugin
1✔
41
 */
1✔
42

1✔
43
/**
1✔
44
 * @typedef {UwaveServer & avvio.Server<UwaveServer>} Boot
1✔
45
 * @typedef {Pick<
1✔
46
 *   import('ioredis').RedisOptions,
1✔
47
 *   'port' | 'host' | 'family' | 'path' | 'db' | 'password' | 'username' | 'tls'
1✔
48
 * >} RedisOptions
1✔
49
 * @typedef {{
1✔
50
 *   port?: number,
1✔
51
 *   sqlite?: string,
1✔
52
 *   mongo?: string,
1✔
53
 *   redis?: string | RedisOptions,
1✔
54
 *   logger?: import('pino').LoggerOptions,
1✔
55
 * } & import('./HttpApi.js').HttpApiOptions} Options
1✔
56
 */
1✔
57

1✔
58
class UwaveServer extends EventEmitter {
132✔
59
  /** @type {import('ioredis').default} */
132✔
60
  redis;
132✔
61

132✔
62
  /** @type {import('http').Server} */
132✔
63
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
64
  server;
132✔
65

132✔
66
  /** @type {import('express').Application} */
132✔
67
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
68
  express;
132✔
69

132✔
70
  /** @type {import('./plugins/acl.js').Acl} */
132✔
71
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
72
  acl;
132✔
73

132✔
74
  /** @type {import('./plugins/bans.js').Bans} */
132✔
75
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
76
  bans;
132✔
77

132✔
78
  /** @type {import('./plugins/booth.js').Booth} */
132✔
79
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
80
  booth;
132✔
81

132✔
82
  /** @type {import('./plugins/chat.js').Chat} */
132✔
83
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
84
  chat;
132✔
85

132✔
86
  /** @type {import('./plugins/configStore.js').ConfigStore} */
132✔
87
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
88
  config;
132✔
89

132✔
90
  /** @type {import('./plugins/emotes.js').Emotes|null} */
132✔
91
  emotes = null;
132✔
92

132✔
93
  /** @type {import('./plugins/history.js').HistoryRepository} */
132✔
94
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
95
  history;
132✔
96

132✔
97
  /** @type {import('./plugins/migrations.js').Migrate} */
132✔
98
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
99
  migrate;
132✔
100

132✔
101
  /** @type {import('./plugins/motd.js').MOTD} */
132✔
102
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
103
  motd;
132✔
104

132✔
105
  /** @type {import('./plugins/passport.js').Passport} */
132✔
106
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
107
  passport;
132✔
108

132✔
109
  /** @type {import('./plugins/playlists.js').PlaylistsRepository} */
132✔
110
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
111
  playlists;
132✔
112

132✔
113
  /** @type {import('./plugins/users.js').UsersRepository} */
132✔
114
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
115
  users;
132✔
116

132✔
117
  /** @type {import('./plugins/waitlist.js').Waitlist} */
132✔
118
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
119
  waitlist;
132✔
120

132✔
121
  /** @type {import('./HttpApi.js').HttpApi} */
132✔
122
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
123
  httpApi;
132✔
124

132✔
125
  /** @type {import('./SocketServer.js').default} */
132✔
126
  // @ts-expect-error TS2564 Definitely assigned in a plugin
132✔
127
  socketServer;
132✔
128

132✔
129
  /**
132✔
130
   * @type {Map<string, Source>}
132✔
131
   */
132✔
132
  #sources = new Map();
132✔
133

132✔
134
  /**
132✔
135
   * @param {Options} options
132✔
136
   */
132✔
137
  constructor(options) {
132✔
138
    super();
132✔
139

132✔
140
    const boot = avvio(this);
132✔
141

132✔
142
    this.logger = pino({
132✔
143
      ...options.logger,
132✔
144
      redact: ['req.headers.cookie', 'res.headers["set-cookie"]'],
132✔
145
    });
132✔
146
    this.locale = i18n.cloneInstance();
132✔
147

132✔
148
    this.options = {
132✔
149
      mongo: DEFAULT_MONGO_URL,
132✔
150
      redis: DEFAULT_REDIS_URL,
132✔
151
      ...options,
132✔
152
    };
132✔
153

132✔
154
    /** @type {Kysely<import('./schema.js').Database>} */
132✔
155
    this.db = new Kysely({
132✔
156
      dialect: new SqliteDialect({
132✔
157
        database: () => connectSqlite(options.sqlite ?? 'uwave_local.sqlite'),
132!
158
      }),
132✔
159
      // dialect: new PostgresDialect({
132✔
160
      //   pool: new pg.Pool({
132✔
161
      //     user: 'uwave',
132✔
162
      //     database: 'uwave_test',
132✔
163
      //   }),
132✔
164
      // }),
132✔
165
      plugins: [
132✔
166
        new UwCamelCasePlugin(),
132✔
167
        new SqliteDateColumnsPlugin(['createdAt', 'updatedAt', 'expiresAt', 'playedAt', 'lastSeenAt']),
132✔
168
      ],
132✔
169
    });
132✔
170

132✔
171
    if (typeof options.redis === 'string') {
132✔
172
      this.redis = new Redis(options.redis, { lazyConnect: true });
132✔
173
    } else {
132!
UNCOV
174
      this.redis = new Redis({ ...options.redis, lazyConnect: true });
×
175
    }
×
176

132✔
177
    this.configureRedis();
132✔
178

132✔
179
    boot.onClose(() => Promise.all([
132✔
180
      this.redis.quit(),
132✔
181
    ]));
132✔
182

132✔
183
    class KeyValue {
132✔
184
      #db;
132✔
185

132✔
186
      /** @param {Kysely<import('./schema.js').Database>} db */
132✔
187
      constructor(db) {
132✔
188
        this.#db = db;
132✔
189
      }
132✔
190

132✔
191
      /** @param {string} key */
132✔
192
      async get(key, db = this.#db) {
132✔
NEW
UNCOV
193
        const row = await db.selectFrom('keyval')
×
NEW
UNCOV
194
          .select('value')
×
NEW
UNCOV
195
          .where('key', '=', key)
×
NEW
UNCOV
196
          .executeTakeFirst();
×
NEW
UNCOV
197
        return row != null ? JSON.parse(/** @type {string} */ (/** @type {unknown} */ (row.value))) : null;
×
NEW
UNCOV
198
      }
×
199

132✔
200
      /**
132✔
201
       * @param {string} key
132✔
202
       * @param {import('type-fest').JsonValue} value
132✔
203
       */
132✔
204
      async set(key, value, db = this.#db) {
132✔
NEW
UNCOV
205
        await db.insertInto('keyval')
×
NEW
UNCOV
206
          .values({ key, value: jsonb(value) })
×
NEW
UNCOV
207
          .onConflict((oc) => oc.column('key').doUpdateSet({ value: jsonb(value) }))
×
NEW
UNCOV
208
          .execute();
×
NEW
UNCOV
209
      }
×
210
    }
132✔
211

132✔
212
    this.keyv = new KeyValue(this.db);
132✔
213

132✔
214
    boot.use(migrations);
132✔
215
    boot.use(configStore);
132✔
216

132✔
217
    boot.use(passport, {
132✔
218
      secret: this.options.secret,
132✔
219
    });
132✔
220

132✔
221
    // Initial API setup
132✔
222
    boot.use(httpApi, {
132✔
223
      secret: this.options.secret,
132✔
224
      helmet: this.options.helmet,
132✔
225
      mailTransport: this.options.mailTransport,
132✔
226
      recaptcha: this.options.recaptcha,
132✔
227
      createPasswordResetEmail: this.options.createPasswordResetEmail,
132✔
228
      onError: this.options.onError,
132✔
229
    });
132✔
230
    boot.use(SocketServer.plugin);
132✔
231

132✔
232
    boot.use(acl);
132✔
233
    boot.use(chat);
132✔
234
    boot.use(motd);
132✔
235
    boot.use(playlists);
132✔
236
    boot.use(users);
132✔
237
    boot.use(bans);
132✔
238
    boot.use(history);
132✔
239
    boot.use(waitlist);
132✔
240
    boot.use(booth);
132✔
241

132✔
242
    boot.use(errorHandling);
132✔
243
  }
132✔
244

132✔
245
  /**
132✔
246
   * An array of registered sources.
132✔
247
   *
132✔
248
   * @type {Source[]}
132✔
249
   */
132✔
250
  get sources() {
132✔
UNCOV
251
    return [...this.#sources.values()];
×
UNCOV
252
  }
×
253

132✔
254
  /**
132✔
255
   * Get or register a source plugin.
132✔
256
   * If the first parameter is a string, returns an existing source plugin.
132✔
257
   * Else, adds a source plugin and returns its wrapped source plugin.
132✔
258
   *
132✔
259
   * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory
132✔
260
   * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin
132✔
261
   * @param {string | Omit<ToSourcePlugin, 'default'> | { default: ToSourcePlugin }} sourcePlugin
132✔
262
   *     Source name or definition.
132✔
263
   *     When a string: Source type name.
132✔
264
   *     Used to signal where a given media item originated from.
132✔
265
   *     When a function or object: Source plugin or plugin factory.
132✔
266
   * @param {object} opts Options to pass to the source plugin. Only used if
132✔
267
   *     a source plugin factory was passed to `sourcePlugin`.
132✔
268
   */
132✔
269
  source(sourcePlugin, opts = {}) {
132✔
270
    if (typeof sourcePlugin === 'string') {
145✔
271
      return this.#sources.get(sourcePlugin);
84✔
272
    }
84✔
273

61✔
274
    const sourceFactory = 'default' in sourcePlugin && sourcePlugin.default ? sourcePlugin.default : sourcePlugin;
145!
275
    if (typeof sourceFactory !== 'function' && typeof sourceFactory !== 'object') {
145!
276
      throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`);
×
UNCOV
277
    }
×
278

61✔
279
    const sourceDefinition = typeof sourceFactory === 'function'
61✔
280
      ? sourceFactory(this, opts)
145✔
281
      : sourceFactory;
145✔
282
    const sourceType = sourceDefinition.name;
145✔
283
    if (typeof sourceType !== 'string') {
145!
UNCOV
284
      throw new TypeError('Source plugin does not specify a name. It may be incompatible with this version of üWave.');
×
UNCOV
285
    }
×
286
    const newSource = new Source(this, sourceType, sourceDefinition);
61✔
287

61✔
288
    this.#sources.set(sourceType, newSource);
61✔
289

61✔
290
    return newSource;
61✔
291
  }
145✔
292

132✔
293
  /**
132✔
294
   * @private
132✔
295
   */
132✔
296
  configureRedis() {
132✔
297
    const log = this.logger.child({ ns: 'uwave:redis' });
132✔
298

132✔
299
    this.redis.on('error', (error) => {
132✔
UNCOV
300
      log.error(error);
×
UNCOV
301
      this.emit('redisError', error);
×
302
    });
132✔
303
    this.redis.on('reconnecting', () => {
132✔
UNCOV
304
      log.info('trying to reconnect...');
×
305
    });
132✔
306

132✔
307
    this.redis.on('end', () => {
132✔
308
      log.info('disconnected');
132✔
309
      this.emit('redisDisconnect');
132✔
310
    });
132✔
311

132✔
312
    this.redis.on('connect', () => {
132✔
313
      log.info('connected');
132✔
314
      this.emit('redisConnect');
132✔
315
    });
132✔
316
  }
132✔
317

132✔
318
  /**
132✔
319
   * Publish an event to the üWave channel.
132✔
320
   *
132✔
321
   * @template {keyof import('./redisMessages.js').ServerActionParameters} CommandName
132✔
322
   * @param {CommandName} command
132✔
323
   * @param {import('./redisMessages.js').ServerActionParameters[CommandName]} data
132✔
324
   */
132✔
325
  publish(command, data) {
132✔
326
    return this.redis.publish('uwave', JSON.stringify({ command, data }));
157✔
327
  }
157✔
328

132✔
329
  async listen() {
132✔
330
    /** @type {import('avvio').Avvio<this>} */
132✔
331
    // @ts-expect-error TS2322
132✔
332
    const boot = this; // eslint-disable-line @typescript-eslint/no-this-alias
132✔
333
    await boot.ready();
132✔
334

132✔
335
    /** @type {(this: import('net').Server, port?: number) => Promise<void>} */
132✔
336
    const listen = promisify(this.server.listen);
132✔
337
    await listen.call(this.server, this.options.port);
132✔
338
  }
132✔
339
}
132✔
340

1✔
341
export default UwaveServer;
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc