+
+ typeof error === 'object' &&
+ error !== null &&
+ 'name' in error &&
+ error.name === 'AbortError';
+
export const useChecksum = (wait: boolean, id?: string) => {
const [resolved, setResolved] = useState(null);
const [loading, setLoading] = useState(true);
+ const resolvedRef = useRef(null);
+ resolvedRef.current = resolved;
useEffect(() => {
- if (!id || wait) return;
+ if (!id || wait) return undefined;
+ const controller = new AbortController();
let cancelled = false;
- const fetchChecksum = async () => {
- setLoading(true);
+ if (resolvedRef.current?.id !== id) setLoading(true);
+ const fetchChecksum = async () => {
try {
- const response = await getChecksum(id);
+ const response = await getChecksum(id, controller.signal);
if (cancelled) return;
setResolved({ id, checksum: response, error: null });
setLoading(false);
} catch (caught) {
- if (cancelled) return;
+ if (cancelled || isAbort(caught)) return;
setResolved({
id,
checksum: null,
@@ -43,6 +52,7 @@ export const useChecksum = (wait: boolean, id?: string) => {
return () => {
cancelled = true;
+ controller.abort();
};
}, [id, wait]);
diff --git a/src/utils/checksum-phrase.test.ts b/src/utils/checksum-phrase.test.ts
new file mode 100644
index 0000000..d0b5ef9
--- /dev/null
+++ b/src/utils/checksum-phrase.test.ts
@@ -0,0 +1,40 @@
+import { addressToChecksum, loadWordList } from 'human-readable-checksum';
+
+import { checksumPhrase } from './checksum-phrase';
+
+jest.mock('human-readable-checksum', () => ({
+ loadWordList: jest.fn(() => ['word']),
+ addressToChecksum: jest.fn(() =>
+ Promise.resolve(['alpha', 'bravo', 'charlie', 'delta', 'echo'])
+ )
+}));
+
+const addressToChecksumMock = addressToChecksum as jest.Mock;
+const loadWordListMock = loadWordList as jest.Mock;
+
+describe('checksumPhrase', () => {
+ it('awaits the async check phrase and joins the words', async () => {
+ addressToChecksumMock.mockClear();
+
+ await expect(checksumPhrase('qz-async')).resolves.toBe(
+ 'alpha-bravo-charlie-delta-echo'
+ );
+ expect(addressToChecksumMock).toHaveBeenCalledWith('qz-async', ['word']);
+
+ const loadsAfterFirst = loadWordListMock.mock.calls.length;
+ await expect(checksumPhrase('qz-async-again')).resolves.toBe(
+ 'alpha-bravo-charlie-delta-echo'
+ );
+ expect(loadWordListMock.mock.calls.length).toBe(loadsAfterFirst);
+ });
+
+ it('rejects when the check phrase cannot be derived', async () => {
+ addressToChecksumMock.mockRejectedValueOnce(
+ new Error('Web Crypto is not available')
+ );
+
+ await expect(checksumPhrase('qz-derive-fail')).rejects.toThrow(
+ 'Web Crypto is not available'
+ );
+ });
+});
diff --git a/src/utils/checksum-phrase.ts b/src/utils/checksum-phrase.ts
new file mode 100644
index 0000000..33e7842
--- /dev/null
+++ b/src/utils/checksum-phrase.ts
@@ -0,0 +1,10 @@
+import { addressToChecksum, loadWordList } from 'human-readable-checksum';
+
+let wordList: string[] = [];
+
+export const checksumPhrase = async (address: string) => {
+ if (wordList.length === 0) wordList = loadWordList();
+
+ const words = await addressToChecksum(address, wordList);
+ return words.join('-');
+};
diff --git a/src/utils/checksum-worker.ts b/src/utils/checksum-worker.ts
new file mode 100644
index 0000000..e71cda9
--- /dev/null
+++ b/src/utils/checksum-worker.ts
@@ -0,0 +1,4 @@
+export const createChecksumWorker = (): Worker =>
+ new Worker(new URL('./checksum.worker.ts', import.meta.url), {
+ type: 'module'
+ });
diff --git a/src/utils/checksum.worker.ts b/src/utils/checksum.worker.ts
new file mode 100644
index 0000000..86d8ef3
--- /dev/null
+++ b/src/utils/checksum.worker.ts
@@ -0,0 +1,32 @@
+import { checksumPhrase } from './checksum-phrase';
+
+type ChecksumRequest = {
+ id: number;
+ address: string;
+};
+
+type ChecksumResponse =
+ | { id: number; checksum: string }
+ | { id: number; error: string };
+
+const scope = globalThis as typeof globalThis & {
+ onmessage: ((event: MessageEvent) => void) | null;
+ postMessage: (message: ChecksumResponse) => void;
+};
+
+const failureMessage = (caught: unknown) => {
+ if (caught instanceof Error && caught.message) return caught.message;
+ return 'Failed to load check phrase';
+};
+
+scope.onmessage = (event) => {
+ const { id, address } = event.data;
+
+ checksumPhrase(address)
+ .then((checksum) => {
+ scope.postMessage({ id, checksum });
+ })
+ .catch((caught: unknown) => {
+ scope.postMessage({ id, error: failureMessage(caught) });
+ });
+};
diff --git a/src/utils/get-checksum.test.ts b/src/utils/get-checksum.test.ts
index e49767c..b34b60c 100644
--- a/src/utils/get-checksum.test.ts
+++ b/src/utils/get-checksum.test.ts
@@ -1,6 +1,4 @@
-import { addressToChecksum } from 'human-readable-checksum';
-
-import { getChecksum } from './get-checksum';
+import { createChecksumQueue } from './get-checksum';
const store = new Map();
Object.assign(globalThis, {
@@ -18,42 +16,166 @@ Object.assign(globalThis, {
}
});
-jest.mock('human-readable-checksum', () => ({
- loadWordList: () => ['word'],
- addressToChecksum: jest.fn(() => [
- 'alpha',
- 'bravo',
- 'charlie',
- 'delta',
- 'echo'
- ])
-}));
+type PostedChecksum = {
+ id: number;
+ address: string;
+};
+
+type FakeChecksumWorker = {
+ onmessage: ((event: MessageEvent) => void) | null;
+ onerror: ((event: ErrorEvent) => void) | null;
+ posted: PostedChecksum[];
+ sent: PostedChecksum[];
+ terminated: boolean;
+ postMessage: (data: PostedChecksum) => void;
+ terminate: () => void;
+};
-const addressToChecksumMock = addressToChecksum as jest.Mock;
+const createFakeWorker = (): FakeChecksumWorker => {
+ const worker: FakeChecksumWorker = {
+ onmessage: null,
+ onerror: null,
+ posted: [],
+ sent: [],
+ terminated: false,
+ postMessage(data) {
+ worker.posted.push(data);
+ worker.sent.push(data);
+ },
+ terminate() {
+ worker.terminated = true;
+ }
+ };
+
+ return worker;
+};
describe('getChecksum', () => {
- it('does not compute the check phrase in the turn that starts loading', async () => {
- addressToChecksumMock.mockClear();
+ let created: FakeChecksumWorker[] = [];
+ let getChecksum: (
+ address?: string,
+ signal?: AbortSignal
+ ) => Promise;
- const pending = getChecksum('qz-defer-1');
+ const latestWorker = () => {
+ const worker = created.at(-1);
+ if (!worker) throw new Error('checksum worker was not created');
+ return worker;
+ };
- expect(addressToChecksumMock).not.toHaveBeenCalled();
+ const respond = (data: { checksum: string } | { error: string }) => {
+ const worker = latestWorker();
+ const message = worker.posted.shift();
+ if (!message) throw new Error('No checksum job is waiting');
+ worker.onmessage?.({
+ data: { id: message.id, ...data }
+ } as MessageEvent);
+ };
- await expect(pending).resolves.toBe('alpha-bravo-charlie-delta-echo');
- expect(addressToChecksumMock).toHaveBeenCalledTimes(1);
+ beforeEach(() => {
+ store.clear();
+ created = [];
+ ({ getChecksum } = createChecksumQueue(() => {
+ const worker = createFakeWorker();
+ created.push(worker);
+ return worker;
+ }));
});
- it('computes a repeated address once', async () => {
- addressToChecksumMock.mockClear();
+ it('returns nothing for a missing address', async () => {
+ await expect(getChecksum()).resolves.toBeNull();
+ expect(created).toHaveLength(0);
+ });
+
+ it('waits for the worker instead of resolving in the turn that starts loading', async () => {
+ let settled = false;
+ const pending = getChecksum('qz-defer').then(() => {
+ settled = true;
+ });
+
+ await Promise.resolve();
+
+ expect(settled).toBe(false);
+ expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-defer']);
+
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await pending;
+ expect(settled).toBe(true);
+ });
+
+ it('sends one address to the worker at a time', async () => {
+ const first = getChecksum('qz-seq-a');
+ const second = getChecksum('qz-seq-b');
- const [first, second] = await Promise.all([
- getChecksum('qz-shared'),
- getChecksum('qz-shared')
+ expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-seq-a']);
+
+ respond({ checksum: 'phrase-a' });
+ await expect(first).resolves.toBe('phrase-a');
+ expect(latestWorker().sent.map((job) => job.address)).toEqual([
+ 'qz-seq-a',
+ 'qz-seq-b'
]);
- expect(first).toBe('alpha-bravo-charlie-delta-echo');
- expect(second).toBe(first);
- expect(addressToChecksumMock).toHaveBeenCalledTimes(1);
+ respond({ checksum: 'phrase-b' });
+ await expect(second).resolves.toBe('phrase-b');
+ });
+
+ it('computes a repeated address once', async () => {
+ const first = getChecksum('qz-shared');
+ const second = getChecksum('qz-shared');
+
+ expect(latestWorker().sent).toHaveLength(1);
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+
+ await expect(first).resolves.toBe('alpha-bravo-charlie-delta-echo');
+ await expect(second).resolves.toBe('alpha-bravo-charlie-delta-echo');
+
+ await expect(getChecksum('qz-shared')).resolves.toBe(
+ 'alpha-bravo-charlie-delta-echo'
+ );
+ expect(latestWorker().sent).toHaveLength(1);
+ });
+
+ it('still resolves a shared address when one caller leaves', async () => {
+ const controller = new AbortController();
+ const left = getChecksum('qz-shared-live', controller.signal);
+ const stayed = getChecksum('qz-shared-live');
+
+ controller.abort();
+
+ await expect(left).rejects.toMatchObject({ name: 'AbortError' });
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await expect(stayed).resolves.toBe('alpha-bravo-charlie-delta-echo');
+ expect(latestWorker().sent).toHaveLength(1);
+ });
+
+ it('does not send an address that left before its turn', async () => {
+ const first = getChecksum('qz-hold');
+ const controller = new AbortController();
+ const skipped = getChecksum('qz-offscreen', controller.signal);
+
+ controller.abort();
+
+ await expect(skipped).rejects.toMatchObject({ name: 'AbortError' });
+ expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-hold']);
+
+ respond({ checksum: 'phrase-hold' });
+ await expect(first).resolves.toBe('phrase-hold');
+ expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-hold']);
+ });
+
+ it('keeps a phrase that finished after its caller left', async () => {
+ const controller = new AbortController();
+ const pending = getChecksum('qz-late', controller.signal);
+
+ controller.abort();
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
+
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await expect(getChecksum('qz-late')).resolves.toBe(
+ 'alpha-bravo-charlie-delta-echo'
+ );
+ expect(latestWorker().sent).toHaveLength(1);
});
it('reuses a check phrase saved in localStorage without computing', async () => {
@@ -61,19 +183,54 @@ describe('getChecksum', () => {
'quantus:checkphrase:qz-stored',
'alpha-bravo-charlie-delta-echo'
);
- addressToChecksumMock.mockClear();
await expect(getChecksum('qz-stored')).resolves.toBe(
'alpha-bravo-charlie-delta-echo'
);
- expect(addressToChecksumMock).not.toHaveBeenCalled();
+ expect(created).toHaveLength(0);
});
it('saves a newly computed check phrase in localStorage', async () => {
- await getChecksum('qz-new-store');
+ const pending = getChecksum('qz-new-store');
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await pending;
expect(localStorage.getItem('quantus:checkphrase:qz-new-store')).toBe(
'alpha-bravo-charlie-delta-echo'
);
});
+
+ it('rejects a derivation error from the worker and continues the queue', async () => {
+ const pending = getChecksum('qz-derive-error');
+ const next = getChecksum('qz-after-derive-error');
+
+ respond({ error: 'Web Crypto is not available' });
+
+ await expect(pending).rejects.toThrow('Web Crypto is not available');
+ expect(latestWorker().posted.map((job) => job.address)).toEqual([
+ 'qz-after-derive-error'
+ ]);
+
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await expect(next).resolves.toBe('alpha-bravo-charlie-delta-echo');
+ });
+
+ it('reports a worker failure without computing on the page', async () => {
+ const pending = getChecksum('qz-worker-fail');
+ const failedWorker = latestWorker();
+
+ failedWorker.onerror?.({} as ErrorEvent);
+
+ await expect(pending).rejects.toThrow('Check phrase worker failed');
+ expect(failedWorker.terminated).toBe(true);
+
+ const next = getChecksum('qz-worker-next');
+ expect(latestWorker()).not.toBe(failedWorker);
+ expect(latestWorker().sent.map((job) => job.address)).toEqual([
+ 'qz-worker-next'
+ ]);
+
+ respond({ checksum: 'alpha-bravo-charlie-delta-echo' });
+ await expect(next).resolves.toBe('alpha-bravo-charlie-delta-echo');
+ });
});
diff --git a/src/utils/get-checksum.ts b/src/utils/get-checksum.ts
index da7687d..563311f 100644
--- a/src/utils/get-checksum.ts
+++ b/src/utils/get-checksum.ts
@@ -1,10 +1,28 @@
-import { addressToChecksum, loadWordList } from 'human-readable-checksum';
+import { createChecksumWorker } from './checksum-worker';
const STORAGE_PREFIX = 'quantus:checkphrase:';
-let wordList: string[] = [];
-const cache = new Map();
-const inflight = new Map>();
+export type ChecksumComputeWorker = {
+ onmessage: ((event: MessageEvent) => void) | null;
+ onerror: ((event: ErrorEvent) => void) | null;
+ postMessage: (message: { id: number; address: string }) => void;
+ terminate: () => void;
+};
+
+type Waiter = {
+ signal?: AbortSignal;
+ resolve: (value: string | null) => void;
+ reject: (error: unknown) => void;
+};
+
+type Job = {
+ address: string;
+ waiters: Waiter[];
+};
+
+type ChecksumResponse =
+ | { id: number; checksum: string }
+ | { id: number; error: string };
const storageKey = (address: string) => `${STORAGE_PREFIX}${address}`;
@@ -24,77 +42,214 @@ const writeStored = (address: string, checksum: string) => {
}
};
-type Job = {
- address: string;
- resolve: (value: string | null) => void;
- reject: (error: unknown) => void;
-};
-
-const queue: Job[] = [];
-let scheduled = false;
-
-const compute = (address: string) => {
- const cached = cache.get(address);
- if (cached) return cached;
+const abortError = () =>
+ new DOMException('The operation was aborted.', 'AbortError');
- if (wordList.length === 0) wordList = loadWordList();
-
- const checksum = addressToChecksum(address, wordList).join('-');
- cache.set(address, checksum);
- writeStored(address, checksum);
- return checksum;
+const workerFailure = (event: ErrorEvent) => {
+ if (event.message) return new Error(event.message);
+ return new Error('Check phrase worker failed');
};
-const drainOne = () => {
- scheduled = false;
- const job = queue.shift();
- if (!job) return;
-
- try {
- job.resolve(compute(job.address));
- } catch (error) {
- job.reject(error);
+const isChecksumResponse = (value: unknown): value is ChecksumResponse => {
+ if (typeof value !== 'object' || value === null || !('id' in value)) {
+ return false;
}
- if (queue.length > 0) scheduleDrain();
-};
+ const { id } = value;
+ if (typeof id !== 'number') return false;
+ if ('checksum' in value && typeof value.checksum === 'string') return true;
+ if ('error' in value && typeof value.error === 'string' && value.error) {
+ return true;
+ }
-const scheduleDrain = () => {
- if (scheduled) return;
- scheduled = true;
- setTimeout(drainOne, 0);
+ return false;
};
-export const getChecksum = (address?: string): Promise => {
- if (!address) return Promise.resolve(null);
+// The worker hashes one address at a time. A queued address is dropped once
+// every caller has left the screen.
+export const createChecksumQueue = (
+ createWorker: () => ChecksumComputeWorker
+) => {
+ const cache = new Map();
+ const jobsByAddress = new Map();
+ const queue: Job[] = [];
+ const settled = new WeakSet();
+ let worker: ChecksumComputeWorker | null = null;
+ let current: { id: number; job: Job } | null = null;
+ let nextId = 0;
+ let pump = () => {};
+
+ const isActive = (waiter: Waiter) =>
+ !settled.has(waiter) && !waiter.signal?.aborted;
+
+ const settle = (waiter: Waiter, action: () => void) => {
+ if (settled.has(waiter)) return;
+ settled.add(waiter);
+ action();
+ };
+
+ const forget = (job: Job) => {
+ if (jobsByAddress.get(job.address) === job) {
+ jobsByAddress.delete(job.address);
+ }
+ };
+
+ const rejectActive = (job: Job, error: unknown) => {
+ job.waiters.forEach((waiter) => {
+ if (!isActive(waiter)) return;
+ settle(waiter, () => waiter.reject(error));
+ });
+ };
- const cached = cache.get(address);
- if (cached) return Promise.resolve(cached);
+ const resolveActive = (job: Job, checksum: string) => {
+ job.waiters.forEach((waiter) => {
+ if (!isActive(waiter)) return;
+ settle(waiter, () => waiter.resolve(checksum));
+ });
+ };
+
+ const failWorker = (error: Error) => {
+ const failed = worker;
+ const running = current;
+ current = null;
+ worker = null;
+ if (failed) {
+ failed.onmessage = null;
+ failed.onerror = null;
+ failed.terminate();
+ }
+
+ if (running) {
+ forget(running.job);
+ rejectActive(running.job, error);
+ }
+
+ queue.splice(0, queue.length).forEach((job) => {
+ forget(job);
+ rejectActive(job, error);
+ });
+ };
+
+ const handleMessage = (event: MessageEvent) => {
+ const running = current;
+ if (!running) return;
+
+ const data = isChecksumResponse(event.data) ? event.data : null;
+ if (!data || data.id !== running.id) return;
+
+ current = null;
+ forget(running.job);
+
+ if ('checksum' in data) {
+ cache.set(running.job.address, data.checksum);
+ writeStored(running.job.address, data.checksum);
+ resolveActive(running.job, data.checksum);
+ } else {
+ rejectActive(running.job, new Error(data.error));
+ }
+
+ pump();
+ };
+
+ const ensureWorker = () => {
+ if (worker) return worker;
+
+ const created = createWorker();
+ created.onmessage = (event) => {
+ try {
+ handleMessage(event);
+ } catch (error) {
+ failWorker(
+ error instanceof Error
+ ? error
+ : new Error('Check phrase worker failed')
+ );
+ }
+ };
+ created.onerror = (event) => {
+ failWorker(workerFailure(event));
+ };
+ worker = created;
+ return created;
+ };
+
+ pump = () => {
+ if (current) return;
+
+ while (queue.length > 0) {
+ const job = queue.shift();
+ if (!job) return;
+
+ if (!job.waiters.some(isActive)) {
+ forget(job);
+ continue;
+ }
- const stored = readStored(address);
- if (stored) {
- cache.set(address, stored);
- return Promise.resolve(stored);
- }
+ const id = nextId;
+ nextId += 1;
+ current = { id, job };
+
+ try {
+ ensureWorker().postMessage({ id, address: job.address });
+ return;
+ } catch (error) {
+ failWorker(
+ error instanceof Error
+ ? error
+ : new Error('Check phrase worker failed')
+ );
+ return;
+ }
+ }
+ };
+
+ const getChecksum = (address?: string, signal?: AbortSignal) => {
+ if (!address) return Promise.resolve(null);
+ if (signal?.aborted) return Promise.reject(abortError());
+
+ const cached = cache.get(address) ?? readStored(address);
+ if (cached) {
+ cache.set(address, cached);
+ return Promise.resolve(cached);
+ }
+
+ return new Promise((resolve, reject) => {
+ const waiter: Waiter = { signal, resolve, reject };
+ const existing = jobsByAddress.get(address);
+ const job = existing ?? { address, waiters: [] };
+ job.waiters.push(waiter);
+
+ if (!existing) {
+ jobsByAddress.set(address, job);
+ queue.push(job);
+ }
- const existing = inflight.get(address);
- if (existing) return existing;
-
- const promise = new Promise((resolve, reject) => {
- queue.push({
- address,
- resolve: (value) => {
- inflight.delete(address);
- resolve(value);
- },
- reject: (error) => {
- inflight.delete(address);
- reject(error);
+ if (signal) {
+ signal.addEventListener(
+ 'abort',
+ () => {
+ settle(waiter, () => waiter.reject(abortError()));
+ if (current?.job === job) return;
+ if (job.waiters.some(isActive)) return;
+
+ const index = queue.indexOf(job);
+ if (index >= 0) queue.splice(index, 1);
+ forget(job);
+ },
+ { once: true }
+ );
}
+
+ pump();
});
- scheduleDrain();
- });
+ };
- inflight.set(address, promise);
- return promise;
+ return { getChecksum };
};
+
+const checksums = createChecksumQueue(createChecksumWorker);
+
+export const getChecksum = (
+ address?: string,
+ signal?: AbortSignal
+): Promise => checksums.getChecksum(address, signal);