Skip to content

Commit 2f382d4

Browse files
committed
Add request queue to base account class
1 parent 01fb96c commit 2f382d4

File tree

5 files changed

+189
-37
lines changed

5 files changed

+189
-37
lines changed

.changeset/spicy-parrots-fly.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"applesauce-accounts": minor
3+
---
4+
5+
Add request queue to base account class

packages/accounts/src/account.test.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { BaseAccount } from "./account.js";
3+
import { SimpleSigner } from "applesauce-signer";
4+
import { finalizeEvent } from "nostr-tools";
5+
6+
describe("BaseAccount", () => {
7+
let signer: SimpleSigner;
8+
beforeEach(() => {
9+
signer = new SimpleSigner();
10+
});
11+
12+
describe("request queue", () => {
13+
it("should queue signing requests by default", async () => {
14+
const account = new BaseAccount(await signer.getPublicKey(), signer);
15+
16+
let resolve: (() => void)[] = [];
17+
vi.spyOn(signer, "signEvent").mockImplementation(() => {
18+
return new Promise((res) => {
19+
resolve.push(() => res(finalizeEvent({ kind: 1, content: "mock", created_at: 0, tags: [] }, signer.key)));
20+
});
21+
});
22+
23+
// make two signing requests
24+
account.signEvent({ kind: 1, content: "first", created_at: 0, tags: [] });
25+
account.signEvent({ kind: 1, content: "second", created_at: 0, tags: [] });
26+
27+
expect(signer.signEvent).toHaveBeenCalledOnce();
28+
expect(signer.signEvent).toHaveBeenCalledWith(expect.objectContaining({ content: "first" }));
29+
30+
// resolve first
31+
resolve.shift()?.();
32+
33+
// wait next tick
34+
await new Promise((res) => setTimeout(res, 0));
35+
36+
expect(signer.signEvent).toHaveBeenCalledTimes(2);
37+
expect(signer.signEvent).toHaveBeenCalledWith(expect.objectContaining({ content: "second" }));
38+
39+
// resolve second
40+
resolve.shift()?.();
41+
});
42+
43+
it("should not use queueing if its disabled", async () => {
44+
const account = new BaseAccount(await signer.getPublicKey(), signer);
45+
account.queueRequests = false;
46+
47+
let resolve: (() => void)[] = [];
48+
vi.spyOn(signer, "signEvent").mockImplementation(() => {
49+
return new Promise((res) => {
50+
resolve.push(() => res(finalizeEvent({ kind: 1, content: "mock", created_at: 0, tags: [] }, signer.key)));
51+
});
52+
});
53+
54+
// make two signing requests
55+
account.signEvent({ kind: 1, content: "first", created_at: 0, tags: [] });
56+
account.signEvent({ kind: 1, content: "second", created_at: 0, tags: [] });
57+
58+
expect(signer.signEvent).toHaveBeenCalledTimes(2);
59+
expect(signer.signEvent).toHaveBeenCalledWith(expect.objectContaining({ content: "first" }));
60+
expect(signer.signEvent).toHaveBeenCalledWith(expect.objectContaining({ content: "second" }));
61+
62+
// resolve first
63+
resolve.shift()?.();
64+
65+
// wait next tick
66+
await new Promise((res) => setTimeout(res, 0));
67+
68+
// resolve second
69+
resolve.shift()?.();
70+
});
71+
});
72+
});

packages/accounts/src/account.ts

+86-17
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@ import { Nip07Interface } from "applesauce-signer";
22
import { nanoid } from "nanoid";
33

44
import { EventTemplate, IAccount, SerializedAccount } from "./types.js";
5+
import { BehaviorSubject } from "rxjs";
6+
import { NostrEvent } from "nostr-tools";
57

6-
// errors
78
export class SignerMismatchError extends Error {}
8-
export class AccountLockedError extends Error {}
99

1010
export class BaseAccount<Signer extends Nip07Interface, SignerData, Metadata extends unknown>
1111
implements IAccount<Signer, SignerData, Metadata>
1212
{
1313
id = nanoid(8);
14-
metadata?: Metadata;
14+
15+
/** Use a queue for sign and encryption/decryption requests so that there is only one request at a time */
16+
queueRequests = true;
17+
18+
metadata$ = new BehaviorSubject<Metadata | undefined>(undefined);
19+
get metadata(): Metadata | undefined {
20+
return this.metadata$.value;
21+
}
22+
set metadata(metadata: Metadata) {
23+
this.metadata$.next(metadata);
24+
}
1525

1626
// encryption interfaces
1727
nip04?:
@@ -35,21 +45,21 @@ export class BaseAccount<Signer extends Nip07Interface, SignerData, Metadata ext
3545
if (this.signer.nip04) {
3646
this.nip04 = {
3747
encrypt: (pubkey, plaintext) => {
38-
return this.signer.nip04!.encrypt(pubkey, plaintext);
48+
return this.waitForLock(() => this.signer.nip04!.encrypt(pubkey, plaintext));
3949
},
4050
decrypt: (pubkey, plaintext) => {
41-
return this.signer.nip04!.decrypt(pubkey, plaintext);
51+
return this.waitForLock(() => this.signer.nip04!.decrypt(pubkey, plaintext));
4252
},
4353
};
4454
}
4555

4656
if (this.signer.nip44) {
4757
this.nip44 = {
4858
encrypt: (pubkey, plaintext) => {
49-
return this.signer.nip44!.encrypt(pubkey, plaintext);
59+
return this.waitForLock(() => this.signer.nip44!.encrypt(pubkey, plaintext));
5060
},
5161
decrypt: (pubkey, plaintext) => {
52-
return this.signer.nip44!.decrypt(pubkey, plaintext);
62+
return this.waitForLock(() => this.signer.nip44!.decrypt(pubkey, plaintext));
5363
},
5464
};
5565
}
@@ -61,21 +71,80 @@ export class BaseAccount<Signer extends Nip07Interface, SignerData, Metadata ext
6171
}
6272

6373
/** Gets the pubkey from the signer */
64-
async getPublicKey() {
65-
// this.checkLocked();
66-
const signerKey = await this.signer.getPublicKey();
67-
if (this.pubkey !== signerKey) throw new Error("Account signer mismatch");
68-
return this.pubkey;
74+
getPublicKey(): string | Promise<string> {
75+
const result = this.signer.getPublicKey();
76+
77+
if (result instanceof Promise)
78+
return result.then((pubkey) => {
79+
if (this.pubkey !== pubkey) throw new SignerMismatchError("Account signer mismatch");
80+
return pubkey;
81+
});
82+
else {
83+
if (this.pubkey !== result) throw new SignerMismatchError("Account signer mismatch");
84+
return result;
85+
}
6986
}
7087

7188
/** sign the event and make sure its signed with the correct pubkey */
72-
async signEvent(template: EventTemplate) {
73-
// this.checkLocked();
89+
signEvent(template: EventTemplate): Promise<NostrEvent> | NostrEvent {
7490
if (!Reflect.has(template, "pubkey")) Reflect.set(template, "pubkey", this.pubkey);
7591

76-
const signed = await this.signer.signEvent(template);
77-
if (signed.pubkey !== this.pubkey) throw new SignerMismatchError("Signer signed with wrong pubkey");
92+
return this.waitForLock(() => {
93+
const result = this.signer.signEvent(template);
94+
95+
if (result instanceof Promise)
96+
return result.then((signed) => {
97+
if (signed.pubkey !== this.pubkey) throw new SignerMismatchError("Signer signed with wrong pubkey");
98+
return signed;
99+
});
100+
else {
101+
if (result.pubkey !== this.pubkey) throw new SignerMismatchError("Signer signed with wrong pubkey");
102+
103+
return result;
104+
}
105+
});
106+
}
107+
108+
/** Resets the request queue */
109+
resetQueue() {
110+
this.lock = null;
111+
this.queueLength = 0;
112+
}
113+
114+
/** internal queue */
115+
protected queueLength = 0;
116+
protected lock: Promise<any> | null = null;
117+
protected waitForLock<T>(fn: () => Promise<T> | T): Promise<T> | T {
118+
if (!this.queueRequests) return fn();
119+
120+
// if there is already a pending request, wait for it
121+
if (this.lock) {
122+
// create a new promise that runs after the lock
123+
const p = this.lock
124+
.then(() => fn())
125+
.finally(() => {
126+
// shorten the queue
127+
this.queueLength--;
128+
129+
// if this was the last request, remove the lock
130+
if (this.queueLength === 0) this.lock = null;
131+
});
78132

79-
return signed;
133+
// set the lock the new promise
134+
this.lock = p;
135+
this.queueLength++;
136+
137+
return p;
138+
} else {
139+
const result = fn();
140+
141+
// if the result is async, set the new lock
142+
if (result instanceof Promise) {
143+
this.lock = result;
144+
this.queueLength = 1;
145+
}
146+
147+
return result;
148+
}
80149
}
81150
}

packages/accounts/src/manager.ts

+23-17
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ import { BehaviorSubject } from "rxjs";
44
import { IAccount, IAccountConstructor, SerializedAccount } from "./types.js";
55

66
export class AccountManager<Metadata extends unknown = any> {
7-
active = new BehaviorSubject<IAccount<any, any, Metadata> | null>(null);
8-
accounts = new BehaviorSubject<Record<string, IAccount<any, any, Metadata>>>({});
97
types = new Map<string, IAccountConstructor<any, any, Metadata>>();
108

9+
active$ = new BehaviorSubject<IAccount<any, any, Metadata> | null>(null);
10+
get active() {
11+
return this.active$.value;
12+
}
13+
14+
accounts$ = new BehaviorSubject<IAccount<any, any, Metadata>[]>([]);
15+
get accounts() {
16+
return this.accounts$.value;
17+
}
18+
1119
// Account type CRUD
1220

1321
/** Add account type class */
@@ -28,37 +36,35 @@ export class AccountManager<Metadata extends unknown = any> {
2836
getAccount<Signer extends Nip07Interface>(
2937
id: string | IAccount<Signer, any, Metadata>,
3038
): IAccount<Signer, any, Metadata> | undefined {
31-
if (typeof id === "string") return this.accounts.value[id];
32-
else if (this.accounts.value[id.id]) return id;
39+
if (typeof id === "string") return this.accounts$.value.find((a) => a.id === id);
40+
else if (this.accounts$.value.includes(id)) return id;
3341
else return undefined;
3442
}
3543

3644
/** Return the first account for a pubkey */
3745
getAccountForPubkey(pubkey: string): IAccount<any, any, Metadata> | undefined {
38-
return Object.values(this.accounts.value).find((account) => account.pubkey === pubkey);
46+
return Object.values(this.accounts$.value).find((account) => account.pubkey === pubkey);
3947
}
4048

4149
/** Returns all accounts for a pubkey */
4250
getAccountsForPubkey(pubkey: string): IAccount<any, any, Metadata>[] {
43-
return Object.values(this.accounts.value).filter((account) => account.pubkey === pubkey);
51+
return Object.values(this.accounts$.value).filter((account) => account.pubkey === pubkey);
4452
}
4553

4654
/** adds an account to the manager */
4755
addAccount(account: IAccount<any, any, Metadata>) {
4856
if (this.getAccount(account.id)) return;
4957

50-
this.accounts.next({
51-
...this.accounts.value,
58+
this.accounts$.next({
59+
...this.accounts$.value,
5260
[account.id]: account,
5361
});
5462
}
5563

5664
/** Removes an account from the manager */
5765
removeAccount(account: string | IAccount<any, any, Metadata>) {
5866
const id = typeof account === "string" ? account : account.id;
59-
const next = { ...this.accounts.value };
60-
delete next[id];
61-
this.accounts.next(next);
67+
this.accounts$.next(this.accounts$.value.filter((a) => a.id !== id));
6268
}
6369

6470
/** Replaces an account with another */
@@ -67,7 +73,7 @@ export class AccountManager<Metadata extends unknown = any> {
6773

6874
// if the old account was active, switch to the new one
6975
const id = typeof account === "string" ? account : account.id;
70-
if (this.active.value?.id === id) this.setActive(account);
76+
if (this.active$.value?.id === id) this.setActive(account);
7177

7278
this.removeAccount(old);
7379
}
@@ -76,20 +82,20 @@ export class AccountManager<Metadata extends unknown = any> {
7682

7783
/** Returns the currently active account */
7884
getActive() {
79-
return this.active.value;
85+
return this.active$.value;
8086
}
8187
/** Sets the currently active account */
8288
setActive(id: string | IAccount<any, any, Metadata>) {
8389
const account = this.getAccount(id);
8490
if (!account) throw new Error("Cant find account with that ID");
8591

86-
if (this.active.value?.id !== account.id) {
87-
this.active.next(account);
92+
if (this.active$.value?.id !== account.id) {
93+
this.active$.next(account);
8894
}
8995
}
9096
/** Clears the currently active account */
9197
clearActive() {
92-
this.active.next(null);
98+
this.active$.next(null);
9399
}
94100

95101
// Metadata CRUD
@@ -112,7 +118,7 @@ export class AccountManager<Metadata extends unknown = any> {
112118

113119
/** Returns an array of serialized accounts */
114120
toJSON(): SerializedAccount<any, Metadata>[] {
115-
return Array.from(Object.values(this.accounts)).map((account) => account.toJSON());
121+
return Array.from(Object.values(this.accounts$)).map((account) => account.toJSON());
116122
}
117123

118124
/**

packages/docs/accounts/manager.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const manager = new AccountManager();
3131
registerCommonAccountTypes(manager);
3232

3333
// subscribe to the active account
34-
manager.active.subscribe((account) => {
34+
manager.active$.subscribe((account) => {
3535
if (account) console.log(`${account.id} is now active`);
3636
else console.log("no account is active");
3737

@@ -69,7 +69,7 @@ const json = JSON.parse(localStorage.getItem("accounts") || "[]");
6969
await manager.fromJSON(json);
7070

7171
// next, subscribe to any accounts added or removed
72-
manager.accounts.subscribe((accounts) => {
72+
manager.accounts$.subscribe((accounts) => {
7373
// save all the accounts into the "accounts" field
7474
localStorage.setItem("accounts", JSON.stringify(manager.toJSON()));
7575
});
@@ -80,7 +80,7 @@ if (localStorage.hasItem("active")) {
8080
}
8181

8282
// subscribe to active changes
83-
manager.active.subscribe((account) => {
83+
manager.active$.subscribe((account) => {
8484
if (account) localStorage.setItem("active", account.id);
8585
else localStorage.clearItem("active");
8686
});

0 commit comments

Comments
 (0)