Skip to content

Commit 669c800

Browse files
lionel-rowesharevb
andauthored
Use async versions of bcrypt methods to avoid freezing browser tab (#204)
* Use async versions of bcrypt methods to avoid freezing browser tab * Update bcryptjs to 3.0.3 and remove setInterval workaround * extract tools strings enhancements * i18n plugin optimization --------- Co-authored-by: ShareVB <sharevb@gmail.com>
1 parent 58db3b3 commit 669c800

File tree

9 files changed

+283
-30
lines changed

9 files changed

+283
-30
lines changed

locales/en.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,14 @@ tools:
233233
placeholder-your-string-to-compare: Your string to compare...
234234
label-your-hash: 'Your hash: '
235235
placeholder-your-hash-to-compare: Your hash to compare...
236-
label-do-they-match: 'Do they match ? '
237236
tag-copy-hash: Copy hash
237+
hashed-string: Hashed string
238+
comparison-result: Comparison result
239+
matched: Matched
240+
no-match: No match
241+
timed-out-after-timeout-period: Timed out after {timeoutPeriod}
242+
hashed-in-elapsed-period: Hashed in {elapsedPeriod}
243+
compared-in-elapsed-period: Compared in {elapsedPeriod}
238244
crontab-generator:
239245
title: Crontab generator
240246
description: >-

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"ansible-vault": "^1.3.0",
102102
"apache-md5": "^1.1.8",
103103
"arr-diff": "^4.0.0",
104-
"bcryptjs": "^2.4.3",
104+
"bcryptjs": "^3.0.3",
105105
"big.js": "^6.2.2",
106106
"braces": "^3.0.3",
107107
"bwip-js": "^4.5.1",

pnpm-lock.yaml

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/extract-tools-strings.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ function processVueComponent(filePath, toolName) {
5454
}
5555

5656
const hasAlreayI18n = filePath.endsWith('.vue')
57-
? content.includes("const { t } = useI18n();")
58-
: content.includes("import { translate as t } from '@/plugins/i18n.plugin';");
57+
? /const\s+\{.*?\bt\b.*?\}\s+=\s+useI18n\(.*?\)/s.test(content)
58+
: /import\s+\{.*?\btranslate\b.*?\}\s+from\s+(['"])@\/plugins\/i18n\.plugin\1/s.test(content);
5959
if (hasAlreayI18n) {
6060
console.log(`Already extracted: ${filePath}`);
6161
return;

src/plugins/i18n.plugin.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { get } from '@vueuse/core';
33
import type { Plugin } from 'vue';
44
import { createI18n } from 'vue-i18n';
55

6+
const DEFAULT_LOCALE = String(import.meta.env.VITE_LANGUAGE || 'en');
7+
68
const i18n = createI18n({
79
legacy: false,
8-
locale: import.meta.env.VITE_LANGUAGE || 'en',
10+
locale: DEFAULT_LOCALE,
911
messages,
1012
});
1113

@@ -15,7 +17,8 @@ export const i18nPlugin: Plugin = {
1517
},
1618
};
1719

18-
export const translate = function (localeKey: string, list: unknown[] = []) {
19-
const hasKey = i18n.global.te(localeKey, get(i18n.global.locale));
20-
return hasKey ? i18n.global.t(localeKey, list) : localeKey;
21-
};
20+
export function getCurrentLocale(): string {
21+
return get(i18n.global.locale);
22+
}
23+
24+
export const translate = i18n.global.t as typeof i18n.global.t;

src/shims.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,12 @@ interface Navigator {
5555
interface FontFaceSet {
5656
add(fontFace: FontFace)
5757
}
58+
59+
// TODO remove once https://github.com/microsoft/TypeScript/issues/60608 is resolved
60+
// eslint-disable-next-line @typescript-eslint/no-namespace
61+
namespace Intl {
62+
class DurationFormat {
63+
constructor(locale?: Intl.LocalesArgument, options?: { style?: 'long' });
64+
format(duration: { seconds?: number; milliseconds?: number }): string;
65+
}
66+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { compare, hash } from 'bcryptjs';
2+
import { assert, describe, expect, test } from 'vitest';
3+
import { type Update, bcryptWithProgressUpdates } from './bcrypt.models';
4+
5+
// simplified polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync
6+
async function fromAsync<T>(iter: AsyncIterable<T>) {
7+
const out: T[] = [];
8+
for await (const val of iter) {
9+
out.push(val);
10+
}
11+
return out;
12+
}
13+
14+
function checkProgressAndGetResult<T>(updates: Update<T>[]) {
15+
const first = updates.at(0);
16+
const penultimate = updates.at(-2);
17+
const last = updates.at(-1);
18+
const allExceptLast = updates.slice(0, -1);
19+
20+
expect(allExceptLast.every(x => x.kind === 'progress')).toBeTruthy();
21+
expect(first).toEqual({ kind: 'progress', progress: 0 });
22+
expect(penultimate).toEqual({ kind: 'progress', progress: 1 });
23+
24+
assert(last != null && last.kind === 'success');
25+
26+
return last;
27+
}
28+
29+
describe('bcrypt models', () => {
30+
describe(bcryptWithProgressUpdates.name, () => {
31+
test('with bcrypt hash function', async () => {
32+
const updates = await fromAsync(bcryptWithProgressUpdates(hash, ['abc', 5]));
33+
const result = checkProgressAndGetResult(updates);
34+
35+
expect(result.value).toMatch(/^\$2a\$05\$.{53}$/);
36+
expect(result.timeTakenMs).toBeGreaterThan(0);
37+
});
38+
39+
test('with bcrypt compare function', async () => {
40+
const updates = await fromAsync(
41+
bcryptWithProgressUpdates(compare, ['abc', '$2a$05$FHzYelm8Qn.IhGP.N8V1TOWFlRTK.8cphbxZSvSFo9B6HGscnQdhy']),
42+
);
43+
const result = checkProgressAndGetResult(updates);
44+
45+
expect(result.value).toBe(true);
46+
expect(result.timeTakenMs).toBeGreaterThan(0);
47+
});
48+
});
49+
});

src/tools/bcrypt/bcrypt.models.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { getCurrentLocale, translate as t } from '@/plugins/i18n.plugin';
2+
3+
Intl.DurationFormat ??= class DurationFormat {
4+
format(duration: { seconds?: number; milliseconds?: number }): string {
5+
return 'seconds' in duration
6+
? `${duration.seconds} seconds`
7+
: `${duration.milliseconds} milliseconds`;
8+
}
9+
};
10+
11+
export type Update<Result> =
12+
| {
13+
kind: 'progress'
14+
progress: number
15+
}
16+
| {
17+
kind: 'success'
18+
value: Result
19+
timeTakenMs: number
20+
}
21+
| {
22+
kind: 'error'
23+
message: string
24+
};
25+
26+
// generic type for the callback versions of bcryptjs's `hash` and `compare`
27+
export type BcryptFn<Param, Result> = (
28+
arg1: string,
29+
arg2: Param,
30+
callback: (err: Error | null, hash: Result) => void,
31+
progressCallback: (percent: number) => void,
32+
) => void;
33+
34+
interface BcryptWithProgressOptions {
35+
signal: AbortSignal
36+
timeoutMs: number
37+
}
38+
39+
export async function* bcryptWithProgressUpdates<Param, Result>(
40+
fn: BcryptFn<Param, Result>,
41+
args: [string, Param],
42+
options?: Partial<BcryptWithProgressOptions>,
43+
): AsyncGenerator<Update<Result>, undefined, undefined> {
44+
const { timeoutMs = 10_000 } = options ?? {};
45+
const signal = AbortSignal.any([
46+
AbortSignal.timeout(timeoutMs),
47+
options?.signal,
48+
].filter(x => x != null));
49+
50+
let res = (_: Update<Result>) => {};
51+
const nextPromise = () => new Promise<Update<Result>>(resolve => res = resolve);
52+
const promises = [nextPromise()];
53+
const nextValue = (value: Update<Result>) => {
54+
res(value);
55+
promises.push(nextPromise());
56+
};
57+
58+
const start = Date.now();
59+
60+
fn(
61+
args[0],
62+
args[1],
63+
(err, result) => {
64+
nextValue(
65+
err == null
66+
? { kind: 'success', value: result, timeTakenMs: Date.now() - start }
67+
: { kind: 'error', message: err.message },
68+
);
69+
},
70+
(progress) => {
71+
if (signal.aborted) {
72+
nextValue({ kind: 'progress', progress: 0 });
73+
if (signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError') {
74+
const message = t('tools.bcrypt.texts.timed-out-after-timeout-period', {
75+
timeoutPeriod: new Intl.DurationFormat(getCurrentLocale(), { style: 'long' })
76+
.format({ seconds: Math.round(timeoutMs / 1000) }),
77+
});
78+
79+
nextValue({ kind: 'error', message });
80+
}
81+
82+
// throw inside callback to cancel execution of hashing/comparing
83+
throw signal.reason;
84+
}
85+
else {
86+
nextValue({ kind: 'progress', progress });
87+
}
88+
},
89+
);
90+
91+
for await (const value of promises) {
92+
yield value;
93+
94+
if (value.kind === 'success' || value.kind === 'error') {
95+
return;
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)