optimization: avoid rebuilding language data too often

Massimo Melina committed Dec 9, 2024 at 19:47 UTC 698cbdaad4530c5053eba0a24e98ea0227e61d92
2 files changed +55 -31
src/expiringCache.ts new
+16
@@ -0,0 +1,16 @@
1 +export function expiringCache<T, K=string>(ttl: number) {
2 + const o = new Map<K,T>()
3 + return Object.assign(o, {
4 + try(k: K, creator: () => T): T {
5 + let ret = o.get(k)
6 + if (ret === undefined) {
7 + ret = creator()
8 + o.set(k, ret)
9 + Promise.resolve(ret).then(() =>
10 + setTimeout(() => o.delete(k), ttl))
11 + }
12 + return ret
13 + },
14 + })
15 +}
16 +
src/lang.ts
+39 -31
@@ -1,5 +1,6 @@
1 import Koa from 'koa'
2 -import { CFG, hasProp, onlyTruthy, tryJson, wantArray } from './misc'
2 +import { CFG, hasProp, onlyTruthy, tryJson } from './misc'
3 +import { expiringCache } from './expiringCache'
4 import { readFile } from 'fs/promises'
5 import { defineConfig } from './config'
6 import { watchLoad } from './watchLoad'
@@ -20,39 +21,46 @@ export function file2code(fn: string) {
21 return fn.replace(PREFIX, '').replace(SUFFIX, '')
22 }
23
23 -export async function getLangData(ctx: Koa.Context) {
24 - const param = String(ctx.query.lang || '')
25 - if (!param && forceLangData)
26 - return forceLangData
27 - const ret: any = {}
28 - const csv = param || ctx.get('Accept-Language') || ''
29 - const langs = wantArray(csv.split(',').map(x => x.toLowerCase()))
30 - let i = 0
31 - while (i < langs.length) {
32 - let k = langs[i] || '' // shut up ts
33 - if (!k || k === EMBEDDED_LANGUAGE) break
34 - const fn = code2file(k)
35 - try { ret[k] = JSON.parse(await readFile(fn, 'utf8')) }
36 - catch {
37 - if (hasProp(EMBEDDED_TRANSLATIONS, k))
38 - ret[k] = EMBEDDED_TRANSLATIONS[k]
39 - else {
40 - do { k = k.substring(0, k.lastIndexOf('-'))
41 - } while (k && langs.includes(k))
42 - if (k) {
43 - langs[i] = k // overwrite and retry
44 - continue
24 +const cache = expiringCache(3_000) // 3 seconds for both a good dx and acceptable performance
25 +export async function getLangData(ctxOrLangCsv: Koa.Context | string) {
26 + if (typeof ctxOrLangCsv !== 'string') {
27 + const ctx = ctxOrLangCsv
28 + const param = String(ctx.query.lang || '')
29 + if (!param && forceLangData)
30 + return forceLangData
31 + ctxOrLangCsv = param || ctx.get('Accept-Language') || ''
32 + }
33 + const csv = ctxOrLangCsv.toLowerCase()
34 + return cache.try(csv, async () => {
35 + const langs = csv.split(',')
36 + const ret: any = {}
37 + let i = 0
38 + while (i < langs.length) {
39 + let k = langs[i] || '' // shut up ts
40 + if (!k || k === EMBEDDED_LANGUAGE) break
41 + const fn = code2file(k)
42 + try { ret[k] = JSON.parse(await readFile(fn, 'utf8')) } // allow external files to override embedded translations
43 + catch {
44 + if (hasProp(EMBEDDED_TRANSLATIONS, k))
45 + ret[k] = EMBEDDED_TRANSLATIONS[k]
46 + else {
47 + do { k = k.substring(0, k.lastIndexOf('-'))
48 + } while (k && langs.includes(k))
49 + if (k) {
50 + langs[i] = k // overwrite and retry
51 + continue
52 + }
53 }
54 }
47 - }
48 - const fromPlugins = onlyTruthy(await Promise.all(mapPlugins(async pl =>
49 - tryJson(await readFile(join(pl.folder, fn), 'utf8').catch(() => ''))?.translate )))
50 - if (fromPlugins.length)
51 - _.defaults((ret[k] ||= {}).translate ||= {}, ...fromPlugins) // be sure we have an entry for k
55 + const fromPlugins = onlyTruthy(await Promise.all(mapPlugins(async pl =>
56 + tryJson(await readFile(join(pl.folder, fn), 'utf8').catch(() => ''))?.translate )))
57 + if (fromPlugins.length)
58 + _.defaults((ret[k] ||= {}).translate ||= {}, ...fromPlugins) // be sure we have an entry for k
59
53 - i++
54 - }
55 - return ret
60 + i++
61 + }
62 + return ret
63 + })
64 }
65
66 let forceLangData: any