@bobfrankston/rmfmail 1.0.708 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/packages/mailx-bus/index.d.ts +120 -0
- package/packages/mailx-bus/index.d.ts.map +1 -0
- package/packages/mailx-bus/index.js +238 -0
- package/packages/mailx-bus/index.js.map +1 -0
- package/packages/mailx-bus/index.ts +264 -0
- package/packages/mailx-bus/package.json +19 -0
- package/packages/mailx-bus/tsconfig.json +9 -0
- package/packages/mailx-service/db-worker-client.d.ts +32 -0
- package/packages/mailx-service/db-worker-client.d.ts.map +1 -0
- package/packages/mailx-service/db-worker-client.js +66 -0
- package/packages/mailx-service/db-worker-client.js.map +1 -0
- package/packages/mailx-service/db-worker-client.ts +75 -0
- package/packages/mailx-service/db-worker.d.ts +39 -0
- package/packages/mailx-service/db-worker.d.ts.map +1 -0
- package/packages/mailx-service/db-worker.js +104 -0
- package/packages/mailx-service/db-worker.js.map +1 -0
- package/packages/mailx-service/db-worker.ts +153 -0
- package/packages/mailx-service/package.json +1 -0
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-24548 → node_modules.npmglobalize-stash-36832}/.package-lock.json +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/rmfmail",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"workspaces": [
|
|
11
11
|
"packages/mailx-types",
|
|
12
|
+
"packages/mailx-bus",
|
|
12
13
|
"packages/mailx-host",
|
|
13
14
|
"packages/mailx-send",
|
|
14
15
|
"packages/mailx-compose",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-bus
|
|
3
|
+
*
|
|
4
|
+
* Typed message bus that connects mailx's factors. Each factor (DB, sync,
|
|
5
|
+
* UI, compose, calendar, …) is a peer that publishes/subscribes to events
|
|
6
|
+
* and serves/calls request-reply methods over the same bus.
|
|
7
|
+
*
|
|
8
|
+
* **Why this exists.** mailx historically had one monolithic service on one
|
|
9
|
+
* Node event loop: UI reads, IMAP sync, body parsing, OAuth refresh, prefetch,
|
|
10
|
+
* calendar polling — all on the same thread. A 14-minute synchronous freeze
|
|
11
|
+
* in any one path froze every other path. There was no separation between
|
|
12
|
+
* "answer a click" and "wait for Dovecot." The bus is the mechanism that
|
|
13
|
+
* makes those independent: a factor freezing is local; other factors keep
|
|
14
|
+
* answering.
|
|
15
|
+
*
|
|
16
|
+
* **Shape.** A bus exposes two primitives:
|
|
17
|
+
* - `publish(topic, payload)` / `subscribe(topic, handler)` — fire-and-
|
|
18
|
+
* forget events. Many subscribers per topic.
|
|
19
|
+
* - `request(method, args) → Promise<result>` / `register(method, handler)`
|
|
20
|
+
* — RPC. One handler per method. Caller awaits the reply.
|
|
21
|
+
*
|
|
22
|
+
* No method-name dispatch on a god object. Each factor declares the topics
|
|
23
|
+
* and methods it owns. Other factors hold a reference to the bus, not to
|
|
24
|
+
* each other.
|
|
25
|
+
*
|
|
26
|
+
* **Two transports, one interface.**
|
|
27
|
+
* - `InProcessBus`: in-memory Map of handlers. Used in tests, in Android
|
|
28
|
+
* (single-threaded JS), and for the same-thread half of a bridge.
|
|
29
|
+
* - `WorkerBus`: connects to a peer via `worker_threads.MessagePort` (Node)
|
|
30
|
+
* or `MessagePort` (browser). Uses `postMessage`, with structured
|
|
31
|
+
* clone, so the two sides can live in different threads.
|
|
32
|
+
*
|
|
33
|
+
* Both implement `Bus`. Callers write against `Bus`. Where the peer lives is
|
|
34
|
+
* a deploy decision, not an API decision.
|
|
35
|
+
*
|
|
36
|
+
* **Buffer/Uint8Array note (Node).** `worker_threads.postMessage` clones a
|
|
37
|
+
* Node `Buffer` as a plain `Uint8Array`. Callers passing binary data through
|
|
38
|
+
* the bus across a worker boundary must accept that the receiving side sees
|
|
39
|
+
* `Uint8Array` — they should re-wrap with `Buffer.from(u.buffer, u.byteOffset,
|
|
40
|
+
* u.byteLength)` before handing the value to APIs that test
|
|
41
|
+
* `Buffer.isBuffer(x)`. The bus does not do this transparently; it is a
|
|
42
|
+
* transport concern that varies by what the payload actually is.
|
|
43
|
+
*
|
|
44
|
+
* **Errors.** A handler that throws is converted to a rejection on the
|
|
45
|
+
* caller's `request()` promise. The error's `message` survives the
|
|
46
|
+
* structured-clone barrier; the stack trace does not (Node strips it).
|
|
47
|
+
*
|
|
48
|
+
* **Lifecycle.** Subscribers receive a `dispose()` function from `subscribe`.
|
|
49
|
+
* Registered methods receive one from `register`. Dispose unhooks. A bus
|
|
50
|
+
* itself has no global teardown — closing the underlying port (when using
|
|
51
|
+
* `WorkerBus`) is the caller's responsibility.
|
|
52
|
+
*/
|
|
53
|
+
/** Method handler — receives the request payload, returns the response. May
|
|
54
|
+
* be async. A throw becomes a request() rejection on the caller. */
|
|
55
|
+
export type MethodHandler<A = any, R = any> = (args: A) => R | Promise<R>;
|
|
56
|
+
/** Topic handler — fire-and-forget; the return value is ignored. */
|
|
57
|
+
export type TopicHandler<P = any> = (payload: P) => void;
|
|
58
|
+
/** Unsubscribe / unregister token returned from `subscribe` / `register`. */
|
|
59
|
+
export type Dispose = () => void;
|
|
60
|
+
export interface Bus {
|
|
61
|
+
/** Fire an event. Returns synchronously; subscribers are invoked
|
|
62
|
+
* asynchronously (next microtask) so a publisher doesn't observe its
|
|
63
|
+
* own re-entry. */
|
|
64
|
+
publish<P = any>(topic: string, payload: P): void;
|
|
65
|
+
/** Listen to events on `topic`. Returns a dispose function. */
|
|
66
|
+
subscribe<P = any>(topic: string, handler: TopicHandler<P>): Dispose;
|
|
67
|
+
/** Call `method` on its registered handler. The result Promise resolves
|
|
68
|
+
* with whatever the handler returned, or rejects with the error. */
|
|
69
|
+
request<A = any, R = any>(method: string, args: A): Promise<R>;
|
|
70
|
+
/** Register the handler for `method`. Only one handler per method —
|
|
71
|
+
* registering twice throws on the second call. Returns a dispose
|
|
72
|
+
* function that unregisters. */
|
|
73
|
+
register<A = any, R = any>(method: string, handler: MethodHandler<A, R>): Dispose;
|
|
74
|
+
}
|
|
75
|
+
/** Single-process bus. Both sides of every call share a JavaScript heap.
|
|
76
|
+
* Used in tests, in Android (single-threaded JS), and on whichever side of
|
|
77
|
+
* a worker boundary is the host.
|
|
78
|
+
*
|
|
79
|
+
* publish() defers subscriber dispatch to the next microtask so a
|
|
80
|
+
* publisher's stack doesn't deepen indefinitely if a handler republishes
|
|
81
|
+
* on the same topic. */
|
|
82
|
+
export declare class InProcessBus implements Bus {
|
|
83
|
+
private topics;
|
|
84
|
+
private methods;
|
|
85
|
+
publish<P>(topic: string, payload: P): void;
|
|
86
|
+
subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose;
|
|
87
|
+
request<A, R>(method: string, args: A): Promise<R>;
|
|
88
|
+
register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose;
|
|
89
|
+
}
|
|
90
|
+
/** Anything that quacks like a MessagePort — Node's `worker_threads.parentPort`
|
|
91
|
+
* and `Worker` instances, browser's `MessagePort`, etc. */
|
|
92
|
+
export interface MessagePortLike {
|
|
93
|
+
postMessage(value: any): void;
|
|
94
|
+
on?(event: "message", handler: (value: any) => void): void;
|
|
95
|
+
addEventListener?(event: "message", handler: (e: MessageEvent) => void): void;
|
|
96
|
+
}
|
|
97
|
+
/** Cross-thread bus. Each side instantiates a `WorkerBus` over its end of a
|
|
98
|
+
* `MessageChannel`. The two sides are mirror images: subscribing on side A
|
|
99
|
+
* causes events published on side B to flow over the wire to A. Requests
|
|
100
|
+
* initiated on side A are served by whoever registered the method on side B.
|
|
101
|
+
*
|
|
102
|
+
* Topology assumed by this implementation: exactly two endpoints. Multi-
|
|
103
|
+
* party busses (>2 peers) need a hub topology and aren't covered here. */
|
|
104
|
+
export declare class WorkerBus implements Bus {
|
|
105
|
+
private port;
|
|
106
|
+
private localSubs;
|
|
107
|
+
/** Methods registered on THIS side. Requests arriving over the wire
|
|
108
|
+
* for these methods are dispatched locally. */
|
|
109
|
+
private localMethods;
|
|
110
|
+
/** Pending requests we sent to the peer, awaiting reply. */
|
|
111
|
+
private pending;
|
|
112
|
+
private nextId;
|
|
113
|
+
constructor(port: MessagePortLike);
|
|
114
|
+
publish<P>(topic: string, payload: P): void;
|
|
115
|
+
subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose;
|
|
116
|
+
request<A, R>(method: string, args: A): Promise<R>;
|
|
117
|
+
register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose;
|
|
118
|
+
private handleIncoming;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH;qEACqE;AACrE,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAE1E,oEAAoE;AACpE,MAAM,MAAM,YAAY,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC;AAEzD,6EAA6E;AAC7E,MAAM,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC;AAEjC,MAAM,WAAW,GAAG;IAChB;;wBAEoB;IACpB,OAAO,CAAC,CAAC,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,CAAC;IAClD,+DAA+D;IAC/D,SAAS,CAAC,CAAC,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;IACrE;yEACqE;IACrE,OAAO,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/D;;qCAEiC;IACjC,QAAQ,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC;CACrF;AAID;;;;;;yBAMyB;AACzB,qBAAa,YAAa,YAAW,GAAG;IACpC,OAAO,CAAC,MAAM,CAAwC;IACtD,OAAO,CAAC,OAAO,CAAoC;IAEnD,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI;IAa3C,SAAS,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO;IAOxD,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAQxD,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO;CAOxE;AAID;4DAC4D;AAC5D,MAAM,WAAW,eAAe;IAC5B,WAAW,CAAC,KAAK,EAAE,GAAG,GAAG,IAAI,CAAC;IAC9B,EAAE,CAAC,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3D,gBAAgB,CAAC,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI,CAAC;CACjF;AAcD;;;;;;2EAM2E;AAC3E,qBAAa,SAAU,YAAW,GAAG;IACjC,OAAO,CAAC,IAAI,CAAkB;IAC9B,OAAO,CAAC,SAAS,CAAwC;IACzD;oDACgD;IAChD,OAAO,CAAC,YAAY,CAAoC;IACxD,4DAA4D;IAC5D,OAAO,CAAC,OAAO,CAA8E;IAC7F,OAAO,CAAC,MAAM,CAAK;gBAEP,IAAI,EAAE,eAAe;IAUjC,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI;IAe3C,SAAS,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO;IAOxD,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAYxD,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO;IAQrE,OAAO,CAAC,cAAc;CA2CzB"}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-bus
|
|
3
|
+
*
|
|
4
|
+
* Typed message bus that connects mailx's factors. Each factor (DB, sync,
|
|
5
|
+
* UI, compose, calendar, …) is a peer that publishes/subscribes to events
|
|
6
|
+
* and serves/calls request-reply methods over the same bus.
|
|
7
|
+
*
|
|
8
|
+
* **Why this exists.** mailx historically had one monolithic service on one
|
|
9
|
+
* Node event loop: UI reads, IMAP sync, body parsing, OAuth refresh, prefetch,
|
|
10
|
+
* calendar polling — all on the same thread. A 14-minute synchronous freeze
|
|
11
|
+
* in any one path froze every other path. There was no separation between
|
|
12
|
+
* "answer a click" and "wait for Dovecot." The bus is the mechanism that
|
|
13
|
+
* makes those independent: a factor freezing is local; other factors keep
|
|
14
|
+
* answering.
|
|
15
|
+
*
|
|
16
|
+
* **Shape.** A bus exposes two primitives:
|
|
17
|
+
* - `publish(topic, payload)` / `subscribe(topic, handler)` — fire-and-
|
|
18
|
+
* forget events. Many subscribers per topic.
|
|
19
|
+
* - `request(method, args) → Promise<result>` / `register(method, handler)`
|
|
20
|
+
* — RPC. One handler per method. Caller awaits the reply.
|
|
21
|
+
*
|
|
22
|
+
* No method-name dispatch on a god object. Each factor declares the topics
|
|
23
|
+
* and methods it owns. Other factors hold a reference to the bus, not to
|
|
24
|
+
* each other.
|
|
25
|
+
*
|
|
26
|
+
* **Two transports, one interface.**
|
|
27
|
+
* - `InProcessBus`: in-memory Map of handlers. Used in tests, in Android
|
|
28
|
+
* (single-threaded JS), and for the same-thread half of a bridge.
|
|
29
|
+
* - `WorkerBus`: connects to a peer via `worker_threads.MessagePort` (Node)
|
|
30
|
+
* or `MessagePort` (browser). Uses `postMessage`, with structured
|
|
31
|
+
* clone, so the two sides can live in different threads.
|
|
32
|
+
*
|
|
33
|
+
* Both implement `Bus`. Callers write against `Bus`. Where the peer lives is
|
|
34
|
+
* a deploy decision, not an API decision.
|
|
35
|
+
*
|
|
36
|
+
* **Buffer/Uint8Array note (Node).** `worker_threads.postMessage` clones a
|
|
37
|
+
* Node `Buffer` as a plain `Uint8Array`. Callers passing binary data through
|
|
38
|
+
* the bus across a worker boundary must accept that the receiving side sees
|
|
39
|
+
* `Uint8Array` — they should re-wrap with `Buffer.from(u.buffer, u.byteOffset,
|
|
40
|
+
* u.byteLength)` before handing the value to APIs that test
|
|
41
|
+
* `Buffer.isBuffer(x)`. The bus does not do this transparently; it is a
|
|
42
|
+
* transport concern that varies by what the payload actually is.
|
|
43
|
+
*
|
|
44
|
+
* **Errors.** A handler that throws is converted to a rejection on the
|
|
45
|
+
* caller's `request()` promise. The error's `message` survives the
|
|
46
|
+
* structured-clone barrier; the stack trace does not (Node strips it).
|
|
47
|
+
*
|
|
48
|
+
* **Lifecycle.** Subscribers receive a `dispose()` function from `subscribe`.
|
|
49
|
+
* Registered methods receive one from `register`. Dispose unhooks. A bus
|
|
50
|
+
* itself has no global teardown — closing the underlying port (when using
|
|
51
|
+
* `WorkerBus`) is the caller's responsibility.
|
|
52
|
+
*/
|
|
53
|
+
// ── In-process implementation ─────────────────────────────────────────────
|
|
54
|
+
/** Single-process bus. Both sides of every call share a JavaScript heap.
|
|
55
|
+
* Used in tests, in Android (single-threaded JS), and on whichever side of
|
|
56
|
+
* a worker boundary is the host.
|
|
57
|
+
*
|
|
58
|
+
* publish() defers subscriber dispatch to the next microtask so a
|
|
59
|
+
* publisher's stack doesn't deepen indefinitely if a handler republishes
|
|
60
|
+
* on the same topic. */
|
|
61
|
+
export class InProcessBus {
|
|
62
|
+
topics = new Map();
|
|
63
|
+
methods = new Map();
|
|
64
|
+
publish(topic, payload) {
|
|
65
|
+
const handlers = this.topics.get(topic);
|
|
66
|
+
if (!handlers || handlers.size === 0)
|
|
67
|
+
return;
|
|
68
|
+
// Snapshot so a subscriber that disposes itself during dispatch
|
|
69
|
+
// doesn't perturb iteration of the live set.
|
|
70
|
+
const snapshot = Array.from(handlers);
|
|
71
|
+
queueMicrotask(() => {
|
|
72
|
+
for (const h of snapshot) {
|
|
73
|
+
try {
|
|
74
|
+
h(payload);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
subscribe(topic, handler) {
|
|
83
|
+
let set = this.topics.get(topic);
|
|
84
|
+
if (!set) {
|
|
85
|
+
set = new Set();
|
|
86
|
+
this.topics.set(topic, set);
|
|
87
|
+
}
|
|
88
|
+
set.add(handler);
|
|
89
|
+
return () => { set.delete(handler); };
|
|
90
|
+
}
|
|
91
|
+
async request(method, args) {
|
|
92
|
+
const handler = this.methods.get(method);
|
|
93
|
+
if (!handler)
|
|
94
|
+
throw new Error(`bus: no handler registered for "${method}"`);
|
|
95
|
+
// Always async — a handler that throws synchronously would otherwise
|
|
96
|
+
// bypass the Promise rejection path. Wrap in Promise.resolve().
|
|
97
|
+
return Promise.resolve().then(() => handler(args));
|
|
98
|
+
}
|
|
99
|
+
register(method, handler) {
|
|
100
|
+
if (this.methods.has(method)) {
|
|
101
|
+
throw new Error(`bus: "${method}" is already registered`);
|
|
102
|
+
}
|
|
103
|
+
this.methods.set(method, handler);
|
|
104
|
+
return () => { this.methods.delete(method); };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Cross-thread bus. Each side instantiates a `WorkerBus` over its end of a
|
|
108
|
+
* `MessageChannel`. The two sides are mirror images: subscribing on side A
|
|
109
|
+
* causes events published on side B to flow over the wire to A. Requests
|
|
110
|
+
* initiated on side A are served by whoever registered the method on side B.
|
|
111
|
+
*
|
|
112
|
+
* Topology assumed by this implementation: exactly two endpoints. Multi-
|
|
113
|
+
* party busses (>2 peers) need a hub topology and aren't covered here. */
|
|
114
|
+
export class WorkerBus {
|
|
115
|
+
port;
|
|
116
|
+
localSubs = new Map();
|
|
117
|
+
/** Methods registered on THIS side. Requests arriving over the wire
|
|
118
|
+
* for these methods are dispatched locally. */
|
|
119
|
+
localMethods = new Map();
|
|
120
|
+
/** Pending requests we sent to the peer, awaiting reply. */
|
|
121
|
+
pending = new Map();
|
|
122
|
+
nextId = 1;
|
|
123
|
+
constructor(port) {
|
|
124
|
+
this.port = port;
|
|
125
|
+
const onMessage = (raw) => {
|
|
126
|
+
const msg = (raw && typeof raw === "object" && "data" in raw) ? raw.data : raw;
|
|
127
|
+
this.handleIncoming(msg);
|
|
128
|
+
};
|
|
129
|
+
if (typeof port.on === "function")
|
|
130
|
+
port.on("message", onMessage);
|
|
131
|
+
else if (typeof port.addEventListener === "function")
|
|
132
|
+
port.addEventListener("message", onMessage);
|
|
133
|
+
}
|
|
134
|
+
publish(topic, payload) {
|
|
135
|
+
// Local subscribers fire immediately (same-side publish/subscribe is
|
|
136
|
+
// allowed). The peer receives a publish message and dispatches there.
|
|
137
|
+
const local = this.localSubs.get(topic);
|
|
138
|
+
if (local && local.size > 0) {
|
|
139
|
+
const snapshot = Array.from(local);
|
|
140
|
+
queueMicrotask(() => {
|
|
141
|
+
for (const h of snapshot) {
|
|
142
|
+
try {
|
|
143
|
+
h(payload);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
this.port.postMessage({ kind: "publish", topic, payload });
|
|
152
|
+
}
|
|
153
|
+
subscribe(topic, handler) {
|
|
154
|
+
let set = this.localSubs.get(topic);
|
|
155
|
+
if (!set) {
|
|
156
|
+
set = new Set();
|
|
157
|
+
this.localSubs.set(topic, set);
|
|
158
|
+
}
|
|
159
|
+
set.add(handler);
|
|
160
|
+
return () => { set.delete(handler); };
|
|
161
|
+
}
|
|
162
|
+
async request(method, args) {
|
|
163
|
+
// Prefer local handler if one is registered on this side (allows a
|
|
164
|
+
// factor to bundle with the bus for in-process testing).
|
|
165
|
+
const local = this.localMethods.get(method);
|
|
166
|
+
if (local)
|
|
167
|
+
return Promise.resolve().then(() => local(args));
|
|
168
|
+
const id = this.nextId++;
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
this.pending.set(id, { resolve, reject });
|
|
171
|
+
this.port.postMessage({ kind: "request", method, args, id });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
register(method, handler) {
|
|
175
|
+
if (this.localMethods.has(method)) {
|
|
176
|
+
throw new Error(`bus: "${method}" is already registered`);
|
|
177
|
+
}
|
|
178
|
+
this.localMethods.set(method, handler);
|
|
179
|
+
return () => { this.localMethods.delete(method); };
|
|
180
|
+
}
|
|
181
|
+
handleIncoming(msg) {
|
|
182
|
+
if (!msg || typeof msg !== "object")
|
|
183
|
+
return;
|
|
184
|
+
switch (msg.kind) {
|
|
185
|
+
case "publish": {
|
|
186
|
+
if (typeof msg.topic !== "string")
|
|
187
|
+
return;
|
|
188
|
+
const subs = this.localSubs.get(msg.topic);
|
|
189
|
+
if (!subs || subs.size === 0)
|
|
190
|
+
return;
|
|
191
|
+
const snapshot = Array.from(subs);
|
|
192
|
+
queueMicrotask(() => {
|
|
193
|
+
for (const h of snapshot) {
|
|
194
|
+
try {
|
|
195
|
+
h(msg.payload);
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
console.error(`[bus] subscriber for "${msg.topic}" threw: ${e?.message || e}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
case "request": {
|
|
205
|
+
if (typeof msg.method !== "string" || typeof msg.id !== "number")
|
|
206
|
+
return;
|
|
207
|
+
const handler = this.localMethods.get(msg.method);
|
|
208
|
+
if (!handler) {
|
|
209
|
+
this.port.postMessage({ kind: "reply", id: msg.id, error: `no handler registered for "${msg.method}"` });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
Promise.resolve()
|
|
213
|
+
.then(() => handler(msg.args))
|
|
214
|
+
.then(result => this.port.postMessage({ kind: "reply", id: msg.id, result }))
|
|
215
|
+
.catch(e => this.port.postMessage({ kind: "reply", id: msg.id, error: e?.message || String(e) }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case "reply": {
|
|
219
|
+
if (typeof msg.id !== "number")
|
|
220
|
+
return;
|
|
221
|
+
const entry = this.pending.get(msg.id);
|
|
222
|
+
if (!entry)
|
|
223
|
+
return;
|
|
224
|
+
this.pending.delete(msg.id);
|
|
225
|
+
if (msg.error)
|
|
226
|
+
entry.reject(new Error(msg.error));
|
|
227
|
+
else
|
|
228
|
+
entry.resolve(msg.result);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// subscribe-add/remove/register-add/remove are reserved for a
|
|
232
|
+
// future optimisation where the bus only forwards events the
|
|
233
|
+
// peer is interested in. Today every publish crosses the wire;
|
|
234
|
+
// subscribers filter locally. Cheap enough at our volumes.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-bus
|
|
3
|
+
*
|
|
4
|
+
* Typed message bus that connects mailx's factors. Each factor (DB, sync,
|
|
5
|
+
* UI, compose, calendar, …) is a peer that publishes/subscribes to events
|
|
6
|
+
* and serves/calls request-reply methods over the same bus.
|
|
7
|
+
*
|
|
8
|
+
* **Why this exists.** mailx historically had one monolithic service on one
|
|
9
|
+
* Node event loop: UI reads, IMAP sync, body parsing, OAuth refresh, prefetch,
|
|
10
|
+
* calendar polling — all on the same thread. A 14-minute synchronous freeze
|
|
11
|
+
* in any one path froze every other path. There was no separation between
|
|
12
|
+
* "answer a click" and "wait for Dovecot." The bus is the mechanism that
|
|
13
|
+
* makes those independent: a factor freezing is local; other factors keep
|
|
14
|
+
* answering.
|
|
15
|
+
*
|
|
16
|
+
* **Shape.** A bus exposes two primitives:
|
|
17
|
+
* - `publish(topic, payload)` / `subscribe(topic, handler)` — fire-and-
|
|
18
|
+
* forget events. Many subscribers per topic.
|
|
19
|
+
* - `request(method, args) → Promise<result>` / `register(method, handler)`
|
|
20
|
+
* — RPC. One handler per method. Caller awaits the reply.
|
|
21
|
+
*
|
|
22
|
+
* No method-name dispatch on a god object. Each factor declares the topics
|
|
23
|
+
* and methods it owns. Other factors hold a reference to the bus, not to
|
|
24
|
+
* each other.
|
|
25
|
+
*
|
|
26
|
+
* **Two transports, one interface.**
|
|
27
|
+
* - `InProcessBus`: in-memory Map of handlers. Used in tests, in Android
|
|
28
|
+
* (single-threaded JS), and for the same-thread half of a bridge.
|
|
29
|
+
* - `WorkerBus`: connects to a peer via `worker_threads.MessagePort` (Node)
|
|
30
|
+
* or `MessagePort` (browser). Uses `postMessage`, with structured
|
|
31
|
+
* clone, so the two sides can live in different threads.
|
|
32
|
+
*
|
|
33
|
+
* Both implement `Bus`. Callers write against `Bus`. Where the peer lives is
|
|
34
|
+
* a deploy decision, not an API decision.
|
|
35
|
+
*
|
|
36
|
+
* **Buffer/Uint8Array note (Node).** `worker_threads.postMessage` clones a
|
|
37
|
+
* Node `Buffer` as a plain `Uint8Array`. Callers passing binary data through
|
|
38
|
+
* the bus across a worker boundary must accept that the receiving side sees
|
|
39
|
+
* `Uint8Array` — they should re-wrap with `Buffer.from(u.buffer, u.byteOffset,
|
|
40
|
+
* u.byteLength)` before handing the value to APIs that test
|
|
41
|
+
* `Buffer.isBuffer(x)`. The bus does not do this transparently; it is a
|
|
42
|
+
* transport concern that varies by what the payload actually is.
|
|
43
|
+
*
|
|
44
|
+
* **Errors.** A handler that throws is converted to a rejection on the
|
|
45
|
+
* caller's `request()` promise. The error's `message` survives the
|
|
46
|
+
* structured-clone barrier; the stack trace does not (Node strips it).
|
|
47
|
+
*
|
|
48
|
+
* **Lifecycle.** Subscribers receive a `dispose()` function from `subscribe`.
|
|
49
|
+
* Registered methods receive one from `register`. Dispose unhooks. A bus
|
|
50
|
+
* itself has no global teardown — closing the underlying port (when using
|
|
51
|
+
* `WorkerBus`) is the caller's responsibility.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Method handler — receives the request payload, returns the response. May
|
|
55
|
+
* be async. A throw becomes a request() rejection on the caller. */
|
|
56
|
+
export type MethodHandler<A = any, R = any> = (args: A) => R | Promise<R>;
|
|
57
|
+
|
|
58
|
+
/** Topic handler — fire-and-forget; the return value is ignored. */
|
|
59
|
+
export type TopicHandler<P = any> = (payload: P) => void;
|
|
60
|
+
|
|
61
|
+
/** Unsubscribe / unregister token returned from `subscribe` / `register`. */
|
|
62
|
+
export type Dispose = () => void;
|
|
63
|
+
|
|
64
|
+
export interface Bus {
|
|
65
|
+
/** Fire an event. Returns synchronously; subscribers are invoked
|
|
66
|
+
* asynchronously (next microtask) so a publisher doesn't observe its
|
|
67
|
+
* own re-entry. */
|
|
68
|
+
publish<P = any>(topic: string, payload: P): void;
|
|
69
|
+
/** Listen to events on `topic`. Returns a dispose function. */
|
|
70
|
+
subscribe<P = any>(topic: string, handler: TopicHandler<P>): Dispose;
|
|
71
|
+
/** Call `method` on its registered handler. The result Promise resolves
|
|
72
|
+
* with whatever the handler returned, or rejects with the error. */
|
|
73
|
+
request<A = any, R = any>(method: string, args: A): Promise<R>;
|
|
74
|
+
/** Register the handler for `method`. Only one handler per method —
|
|
75
|
+
* registering twice throws on the second call. Returns a dispose
|
|
76
|
+
* function that unregisters. */
|
|
77
|
+
register<A = any, R = any>(method: string, handler: MethodHandler<A, R>): Dispose;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── In-process implementation ─────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Single-process bus. Both sides of every call share a JavaScript heap.
|
|
83
|
+
* Used in tests, in Android (single-threaded JS), and on whichever side of
|
|
84
|
+
* a worker boundary is the host.
|
|
85
|
+
*
|
|
86
|
+
* publish() defers subscriber dispatch to the next microtask so a
|
|
87
|
+
* publisher's stack doesn't deepen indefinitely if a handler republishes
|
|
88
|
+
* on the same topic. */
|
|
89
|
+
export class InProcessBus implements Bus {
|
|
90
|
+
private topics = new Map<string, Set<TopicHandler>>();
|
|
91
|
+
private methods = new Map<string, MethodHandler>();
|
|
92
|
+
|
|
93
|
+
publish<P>(topic: string, payload: P): void {
|
|
94
|
+
const handlers = this.topics.get(topic);
|
|
95
|
+
if (!handlers || handlers.size === 0) return;
|
|
96
|
+
// Snapshot so a subscriber that disposes itself during dispatch
|
|
97
|
+
// doesn't perturb iteration of the live set.
|
|
98
|
+
const snapshot = Array.from(handlers);
|
|
99
|
+
queueMicrotask(() => {
|
|
100
|
+
for (const h of snapshot) {
|
|
101
|
+
try { h(payload); } catch (e: any) { console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`); }
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose {
|
|
107
|
+
let set = this.topics.get(topic);
|
|
108
|
+
if (!set) { set = new Set(); this.topics.set(topic, set); }
|
|
109
|
+
set.add(handler as TopicHandler);
|
|
110
|
+
return () => { set!.delete(handler as TopicHandler); };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async request<A, R>(method: string, args: A): Promise<R> {
|
|
114
|
+
const handler = this.methods.get(method);
|
|
115
|
+
if (!handler) throw new Error(`bus: no handler registered for "${method}"`);
|
|
116
|
+
// Always async — a handler that throws synchronously would otherwise
|
|
117
|
+
// bypass the Promise rejection path. Wrap in Promise.resolve().
|
|
118
|
+
return Promise.resolve().then(() => handler(args)) as Promise<R>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose {
|
|
122
|
+
if (this.methods.has(method)) {
|
|
123
|
+
throw new Error(`bus: "${method}" is already registered`);
|
|
124
|
+
}
|
|
125
|
+
this.methods.set(method, handler as MethodHandler);
|
|
126
|
+
return () => { this.methods.delete(method); };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Worker / MessagePort implementation ───────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** Anything that quacks like a MessagePort — Node's `worker_threads.parentPort`
|
|
133
|
+
* and `Worker` instances, browser's `MessagePort`, etc. */
|
|
134
|
+
export interface MessagePortLike {
|
|
135
|
+
postMessage(value: any): void;
|
|
136
|
+
on?(event: "message", handler: (value: any) => void): void; // Node
|
|
137
|
+
addEventListener?(event: "message", handler: (e: MessageEvent) => void): void; // browser
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface WireMessage {
|
|
141
|
+
kind: "publish" | "request" | "reply" | "subscribe-add" | "subscribe-remove" | "register-add" | "register-remove";
|
|
142
|
+
topic?: string;
|
|
143
|
+
method?: string;
|
|
144
|
+
payload?: any;
|
|
145
|
+
args?: any;
|
|
146
|
+
result?: any;
|
|
147
|
+
error?: string;
|
|
148
|
+
/** Correlation id for request/reply. */
|
|
149
|
+
id?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Cross-thread bus. Each side instantiates a `WorkerBus` over its end of a
|
|
153
|
+
* `MessageChannel`. The two sides are mirror images: subscribing on side A
|
|
154
|
+
* causes events published on side B to flow over the wire to A. Requests
|
|
155
|
+
* initiated on side A are served by whoever registered the method on side B.
|
|
156
|
+
*
|
|
157
|
+
* Topology assumed by this implementation: exactly two endpoints. Multi-
|
|
158
|
+
* party busses (>2 peers) need a hub topology and aren't covered here. */
|
|
159
|
+
export class WorkerBus implements Bus {
|
|
160
|
+
private port: MessagePortLike;
|
|
161
|
+
private localSubs = new Map<string, Set<TopicHandler>>();
|
|
162
|
+
/** Methods registered on THIS side. Requests arriving over the wire
|
|
163
|
+
* for these methods are dispatched locally. */
|
|
164
|
+
private localMethods = new Map<string, MethodHandler>();
|
|
165
|
+
/** Pending requests we sent to the peer, awaiting reply. */
|
|
166
|
+
private pending = new Map<number, { resolve: (r: any) => void; reject: (e: any) => void }>();
|
|
167
|
+
private nextId = 1;
|
|
168
|
+
|
|
169
|
+
constructor(port: MessagePortLike) {
|
|
170
|
+
this.port = port;
|
|
171
|
+
const onMessage = (raw: any): void => {
|
|
172
|
+
const msg = (raw && typeof raw === "object" && "data" in raw) ? (raw as MessageEvent).data : raw;
|
|
173
|
+
this.handleIncoming(msg as WireMessage);
|
|
174
|
+
};
|
|
175
|
+
if (typeof port.on === "function") port.on("message", onMessage);
|
|
176
|
+
else if (typeof port.addEventListener === "function") port.addEventListener("message", onMessage);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
publish<P>(topic: string, payload: P): void {
|
|
180
|
+
// Local subscribers fire immediately (same-side publish/subscribe is
|
|
181
|
+
// allowed). The peer receives a publish message and dispatches there.
|
|
182
|
+
const local = this.localSubs.get(topic);
|
|
183
|
+
if (local && local.size > 0) {
|
|
184
|
+
const snapshot = Array.from(local);
|
|
185
|
+
queueMicrotask(() => {
|
|
186
|
+
for (const h of snapshot) {
|
|
187
|
+
try { h(payload); } catch (e: any) { console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`); }
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
this.port.postMessage({ kind: "publish", topic, payload } as WireMessage);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose {
|
|
195
|
+
let set = this.localSubs.get(topic);
|
|
196
|
+
if (!set) { set = new Set(); this.localSubs.set(topic, set); }
|
|
197
|
+
set.add(handler as TopicHandler);
|
|
198
|
+
return () => { set!.delete(handler as TopicHandler); };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async request<A, R>(method: string, args: A): Promise<R> {
|
|
202
|
+
// Prefer local handler if one is registered on this side (allows a
|
|
203
|
+
// factor to bundle with the bus for in-process testing).
|
|
204
|
+
const local = this.localMethods.get(method);
|
|
205
|
+
if (local) return Promise.resolve().then(() => local(args)) as Promise<R>;
|
|
206
|
+
const id = this.nextId++;
|
|
207
|
+
return new Promise<R>((resolve, reject) => {
|
|
208
|
+
this.pending.set(id, { resolve, reject });
|
|
209
|
+
this.port.postMessage({ kind: "request", method, args, id } as WireMessage);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose {
|
|
214
|
+
if (this.localMethods.has(method)) {
|
|
215
|
+
throw new Error(`bus: "${method}" is already registered`);
|
|
216
|
+
}
|
|
217
|
+
this.localMethods.set(method, handler as MethodHandler);
|
|
218
|
+
return () => { this.localMethods.delete(method); };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private handleIncoming(msg: WireMessage): void {
|
|
222
|
+
if (!msg || typeof msg !== "object") return;
|
|
223
|
+
switch (msg.kind) {
|
|
224
|
+
case "publish": {
|
|
225
|
+
if (typeof msg.topic !== "string") return;
|
|
226
|
+
const subs = this.localSubs.get(msg.topic);
|
|
227
|
+
if (!subs || subs.size === 0) return;
|
|
228
|
+
const snapshot = Array.from(subs);
|
|
229
|
+
queueMicrotask(() => {
|
|
230
|
+
for (const h of snapshot) {
|
|
231
|
+
try { h(msg.payload); } catch (e: any) { console.error(`[bus] subscriber for "${msg.topic}" threw: ${e?.message || e}`); }
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
case "request": {
|
|
237
|
+
if (typeof msg.method !== "string" || typeof msg.id !== "number") return;
|
|
238
|
+
const handler = this.localMethods.get(msg.method);
|
|
239
|
+
if (!handler) {
|
|
240
|
+
this.port.postMessage({ kind: "reply", id: msg.id, error: `no handler registered for "${msg.method}"` } as WireMessage);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
Promise.resolve()
|
|
244
|
+
.then(() => handler(msg.args))
|
|
245
|
+
.then(result => this.port.postMessage({ kind: "reply", id: msg.id, result } as WireMessage))
|
|
246
|
+
.catch(e => this.port.postMessage({ kind: "reply", id: msg.id, error: e?.message || String(e) } as WireMessage));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
case "reply": {
|
|
250
|
+
if (typeof msg.id !== "number") return;
|
|
251
|
+
const entry = this.pending.get(msg.id);
|
|
252
|
+
if (!entry) return;
|
|
253
|
+
this.pending.delete(msg.id);
|
|
254
|
+
if (msg.error) entry.reject(new Error(msg.error));
|
|
255
|
+
else entry.resolve(msg.result);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// subscribe-add/remove/register-add/remove are reserved for a
|
|
259
|
+
// future optimisation where the bus only forwards events the
|
|
260
|
+
// peer is interested in. Today every publish crosses the wire;
|
|
261
|
+
// subscribers filter locally. Cheap enough at our volumes.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bobfrankston/mailx-bus",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"release": "npmglobalize"
|
|
10
|
+
},
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/BobFrankston/mailx-bus.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread client for the DB factor worker.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `db-worker.js` in a Node `worker_thread`, performs the init
|
|
5
|
+
* handshake (passes dbPath + storePath), and returns a `WorkerBus` the
|
|
6
|
+
* caller can use to send read requests and subscribe to DB events.
|
|
7
|
+
*
|
|
8
|
+
* The init handshake is a single `{kind:"init"}` postMessage followed by an
|
|
9
|
+
* `{kind:"init-ok"}` or `{kind:"init-error"}` reply. This is done OUTSIDE
|
|
10
|
+
* the WorkerBus message protocol so a bus method can't accidentally race
|
|
11
|
+
* the construction of LocalStore inside the worker.
|
|
12
|
+
*
|
|
13
|
+
* Failure mode: if the worker can't load (the file is missing in a packaged
|
|
14
|
+
* build, the DB can't be opened, etc.), `spawnDbWorker()` rejects. The
|
|
15
|
+
* caller can fall back to in-process LocalStore — same code, same answers,
|
|
16
|
+
* just shares the event loop with whatever else is running on main. That's
|
|
17
|
+
* the legacy path; the worker is the new default but we don't paint
|
|
18
|
+
* ourselves into a corner.
|
|
19
|
+
*/
|
|
20
|
+
import { Worker } from "node:worker_threads";
|
|
21
|
+
import { WorkerBus } from "@bobfrankston/mailx-bus";
|
|
22
|
+
export interface SpawnedDbWorker {
|
|
23
|
+
bus: WorkerBus;
|
|
24
|
+
worker: Worker;
|
|
25
|
+
/** Stop the worker. After this, requests over the bus reject. */
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export declare function spawnDbWorker(opts: {
|
|
29
|
+
dbPath: string;
|
|
30
|
+
storePath: string;
|
|
31
|
+
}): Promise<SpawnedDbWorker>;
|
|
32
|
+
//# sourceMappingURL=db-worker-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db-worker-client.d.ts","sourceRoot":"","sources":["db-worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAG7C,OAAO,EAAE,SAAS,EAAwB,MAAM,yBAAyB,CAAC;AAE1E,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,SAAS,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CA0CzG"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread client for the DB factor worker.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `db-worker.js` in a Node `worker_thread`, performs the init
|
|
5
|
+
* handshake (passes dbPath + storePath), and returns a `WorkerBus` the
|
|
6
|
+
* caller can use to send read requests and subscribe to DB events.
|
|
7
|
+
*
|
|
8
|
+
* The init handshake is a single `{kind:"init"}` postMessage followed by an
|
|
9
|
+
* `{kind:"init-ok"}` or `{kind:"init-error"}` reply. This is done OUTSIDE
|
|
10
|
+
* the WorkerBus message protocol so a bus method can't accidentally race
|
|
11
|
+
* the construction of LocalStore inside the worker.
|
|
12
|
+
*
|
|
13
|
+
* Failure mode: if the worker can't load (the file is missing in a packaged
|
|
14
|
+
* build, the DB can't be opened, etc.), `spawnDbWorker()` rejects. The
|
|
15
|
+
* caller can fall back to in-process LocalStore — same code, same answers,
|
|
16
|
+
* just shares the event loop with whatever else is running on main. That's
|
|
17
|
+
* the legacy path; the worker is the new default but we don't paint
|
|
18
|
+
* ourselves into a corner.
|
|
19
|
+
*/
|
|
20
|
+
import { Worker } from "node:worker_threads";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { WorkerBus } from "@bobfrankston/mailx-bus";
|
|
24
|
+
export async function spawnDbWorker(opts) {
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const workerPath = join(here, "db-worker.js");
|
|
27
|
+
const worker = new Worker(workerPath);
|
|
28
|
+
// Don't let the worker prevent process exit. Phase 1 design choice — the
|
|
29
|
+
// service shuts down cleanly via its own gracefulShutdown which closes
|
|
30
|
+
// the worker explicitly; without unref, a stuck worker could block exit.
|
|
31
|
+
worker.unref();
|
|
32
|
+
// Init handshake — promise resolves on init-ok, rejects on init-error.
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const onMessage = (msg) => {
|
|
35
|
+
if (!msg || typeof msg !== "object")
|
|
36
|
+
return;
|
|
37
|
+
if (msg.kind === "init-ok") {
|
|
38
|
+
worker.off("message", onMessage);
|
|
39
|
+
resolve();
|
|
40
|
+
}
|
|
41
|
+
else if (msg.kind === "init-error") {
|
|
42
|
+
worker.off("message", onMessage);
|
|
43
|
+
reject(new Error(`db-worker init: ${msg.error}`));
|
|
44
|
+
}
|
|
45
|
+
// Ignore other messages (bus traffic) — the bus is built AFTER
|
|
46
|
+
// we resolve, so any bus message arriving here is stale.
|
|
47
|
+
};
|
|
48
|
+
worker.on("message", onMessage);
|
|
49
|
+
worker.on("error", (e) => {
|
|
50
|
+
worker.off("message", onMessage);
|
|
51
|
+
reject(e);
|
|
52
|
+
});
|
|
53
|
+
worker.postMessage({ kind: "init", dbPath: opts.dbPath, storePath: opts.storePath });
|
|
54
|
+
});
|
|
55
|
+
// Wrap the post-init port in a WorkerBus on this side. The worker has
|
|
56
|
+
// already wrapped its parentPort in a WorkerBus inside its init handler.
|
|
57
|
+
const bus = new WorkerBus(worker);
|
|
58
|
+
return {
|
|
59
|
+
bus,
|
|
60
|
+
worker,
|
|
61
|
+
async close() {
|
|
62
|
+
await worker.terminate();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=db-worker-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db-worker-client.js","sourceRoot":"","sources":["db-worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAwB,MAAM,yBAAyB,CAAC;AAS1E,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAA2C;IAC3E,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IAC9C,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,UAAU,CAAC,CAAC;IACtC,yEAAyE;IACzE,uEAAuE;IACvE,yEAAyE;IACzE,MAAM,CAAC,KAAK,EAAE,CAAC;IAEf,uEAAuE;IACvE,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,MAAM,SAAS,GAAG,CAAC,GAAQ,EAAQ,EAAE;YACjC,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;gBAAE,OAAO;YAC5C,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBACzB,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;gBACjC,OAAO,EAAE,CAAC;YACd,CAAC;iBAAM,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;gBACnC,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;gBACjC,MAAM,CAAC,IAAI,KAAK,CAAC,mBAAmB,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACtD,CAAC;YACD,+DAA+D;YAC/D,yDAAyD;QAC7D,CAAC,CAAC;QACF,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAChC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAQ,EAAE,EAAE;YAC5B,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;YACjC,MAAM,CAAC,CAAC,CAAC,CAAC;QACd,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;IACzF,CAAC,CAAC,CAAC;IAEH,sEAAsE;IACtE,yEAAyE;IACzE,MAAM,GAAG,GAAG,IAAI,SAAS,CAAC,MAAoC,CAAC,CAAC;IAEhE,OAAO;QACH,GAAG;QACH,MAAM;QACN,KAAK,CAAC,KAAK;YACP,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC;QAC7B,CAAC;KACJ,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread client for the DB factor worker.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `db-worker.js` in a Node `worker_thread`, performs the init
|
|
5
|
+
* handshake (passes dbPath + storePath), and returns a `WorkerBus` the
|
|
6
|
+
* caller can use to send read requests and subscribe to DB events.
|
|
7
|
+
*
|
|
8
|
+
* The init handshake is a single `{kind:"init"}` postMessage followed by an
|
|
9
|
+
* `{kind:"init-ok"}` or `{kind:"init-error"}` reply. This is done OUTSIDE
|
|
10
|
+
* the WorkerBus message protocol so a bus method can't accidentally race
|
|
11
|
+
* the construction of LocalStore inside the worker.
|
|
12
|
+
*
|
|
13
|
+
* Failure mode: if the worker can't load (the file is missing in a packaged
|
|
14
|
+
* build, the DB can't be opened, etc.), `spawnDbWorker()` rejects. The
|
|
15
|
+
* caller can fall back to in-process LocalStore — same code, same answers,
|
|
16
|
+
* just shares the event loop with whatever else is running on main. That's
|
|
17
|
+
* the legacy path; the worker is the new default but we don't paint
|
|
18
|
+
* ourselves into a corner.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Worker } from "node:worker_threads";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { WorkerBus, type MessagePortLike } from "@bobfrankston/mailx-bus";
|
|
25
|
+
|
|
26
|
+
export interface SpawnedDbWorker {
|
|
27
|
+
bus: WorkerBus;
|
|
28
|
+
worker: Worker;
|
|
29
|
+
/** Stop the worker. After this, requests over the bus reject. */
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function spawnDbWorker(opts: { dbPath: string; storePath: string }): Promise<SpawnedDbWorker> {
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const workerPath = join(here, "db-worker.js");
|
|
36
|
+
const worker = new Worker(workerPath);
|
|
37
|
+
// Don't let the worker prevent process exit. Phase 1 design choice — the
|
|
38
|
+
// service shuts down cleanly via its own gracefulShutdown which closes
|
|
39
|
+
// the worker explicitly; without unref, a stuck worker could block exit.
|
|
40
|
+
worker.unref();
|
|
41
|
+
|
|
42
|
+
// Init handshake — promise resolves on init-ok, rejects on init-error.
|
|
43
|
+
await new Promise<void>((resolve, reject) => {
|
|
44
|
+
const onMessage = (msg: any): void => {
|
|
45
|
+
if (!msg || typeof msg !== "object") return;
|
|
46
|
+
if (msg.kind === "init-ok") {
|
|
47
|
+
worker.off("message", onMessage);
|
|
48
|
+
resolve();
|
|
49
|
+
} else if (msg.kind === "init-error") {
|
|
50
|
+
worker.off("message", onMessage);
|
|
51
|
+
reject(new Error(`db-worker init: ${msg.error}`));
|
|
52
|
+
}
|
|
53
|
+
// Ignore other messages (bus traffic) — the bus is built AFTER
|
|
54
|
+
// we resolve, so any bus message arriving here is stale.
|
|
55
|
+
};
|
|
56
|
+
worker.on("message", onMessage);
|
|
57
|
+
worker.on("error", (e: Error) => {
|
|
58
|
+
worker.off("message", onMessage);
|
|
59
|
+
reject(e);
|
|
60
|
+
});
|
|
61
|
+
worker.postMessage({ kind: "init", dbPath: opts.dbPath, storePath: opts.storePath });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Wrap the post-init port in a WorkerBus on this side. The worker has
|
|
65
|
+
// already wrapped its parentPort in a WorkerBus inside its init handler.
|
|
66
|
+
const bus = new WorkerBus(worker as unknown as MessagePortLike);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
bus,
|
|
70
|
+
worker,
|
|
71
|
+
async close(): Promise<void> {
|
|
72
|
+
await worker.terminate();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB factor — Node worker thread.
|
|
3
|
+
*
|
|
4
|
+
* Owns the SQLite handle and FileMessageStore. Serves the UI-read surface
|
|
5
|
+
* (getFolders, getMessages, getUnifiedInbox, getMessage, searchMessages,
|
|
6
|
+
* searchContacts, getThreadMessages) over `mailx-bus`. Subscribes to write
|
|
7
|
+
* events from the sync factor (Phase 2) and emits `db:messageAdded` /
|
|
8
|
+
* `db:messageRemoved` / `db:flagChanged` so the UI can update incrementally
|
|
9
|
+
* without re-asking.
|
|
10
|
+
*
|
|
11
|
+
* **Why a worker.** Until now every UI click had to traverse the same Node
|
|
12
|
+
* event loop the sync code runs on. A 14-minute synchronous freeze in the
|
|
13
|
+
* sync path (Dovecot SELECT hang, Prefirst.OIA backfill loop) starved every
|
|
14
|
+
* UI read. The IPC pipe handler couldn't even be scheduled. From the user's
|
|
15
|
+
* perspective: Sent shows empty, message bodies never load, autocomplete
|
|
16
|
+
* dies, drafts can't save. All of those have one cause: the read path
|
|
17
|
+
* shares an event loop with the write/sync path.
|
|
18
|
+
*
|
|
19
|
+
* In this worker, the read path is on its own thread. Sync can freeze for
|
|
20
|
+
* an hour and the user's click still gets a 5 ms answer from local SQLite.
|
|
21
|
+
*
|
|
22
|
+
* **Topology**. Main thread spawns this worker once, holds a `WorkerBus` over
|
|
23
|
+
* the `parentPort`. The worker also holds a `WorkerBus` over `parentPort` and
|
|
24
|
+
* registers its handlers. Two ends of the same channel, mirror APIs.
|
|
25
|
+
*
|
|
26
|
+
* **DB connection**. `better-sqlite3` is in-process synchronous, so the
|
|
27
|
+
* worker opens its OWN connection to the same `.db` file. WAL mode (set in
|
|
28
|
+
* `MailxDB`) makes concurrent readers safe; writes still happen on the main
|
|
29
|
+
* thread for Phase 1 and the worker sees committed rows immediately.
|
|
30
|
+
* Phase 2 moves writes to the sync worker, which routes through this worker
|
|
31
|
+
* for any UI-visible mutation so the LRU/cache here can update in lock-step.
|
|
32
|
+
*
|
|
33
|
+
* **No mailparser here.** Body parsing is the parse-worker's job. This
|
|
34
|
+
* worker reads the .eml bytes off disk and delegates parsing via
|
|
35
|
+
* `parseSerial` (which already postMessages to its own worker). Two workers
|
|
36
|
+
* either side of this thread; the read coordinator is purely an indexer.
|
|
37
|
+
*/
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=db-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db-worker.d.ts","sourceRoot":"","sources":["db-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB factor — Node worker thread.
|
|
3
|
+
*
|
|
4
|
+
* Owns the SQLite handle and FileMessageStore. Serves the UI-read surface
|
|
5
|
+
* (getFolders, getMessages, getUnifiedInbox, getMessage, searchMessages,
|
|
6
|
+
* searchContacts, getThreadMessages) over `mailx-bus`. Subscribes to write
|
|
7
|
+
* events from the sync factor (Phase 2) and emits `db:messageAdded` /
|
|
8
|
+
* `db:messageRemoved` / `db:flagChanged` so the UI can update incrementally
|
|
9
|
+
* without re-asking.
|
|
10
|
+
*
|
|
11
|
+
* **Why a worker.** Until now every UI click had to traverse the same Node
|
|
12
|
+
* event loop the sync code runs on. A 14-minute synchronous freeze in the
|
|
13
|
+
* sync path (Dovecot SELECT hang, Prefirst.OIA backfill loop) starved every
|
|
14
|
+
* UI read. The IPC pipe handler couldn't even be scheduled. From the user's
|
|
15
|
+
* perspective: Sent shows empty, message bodies never load, autocomplete
|
|
16
|
+
* dies, drafts can't save. All of those have one cause: the read path
|
|
17
|
+
* shares an event loop with the write/sync path.
|
|
18
|
+
*
|
|
19
|
+
* In this worker, the read path is on its own thread. Sync can freeze for
|
|
20
|
+
* an hour and the user's click still gets a 5 ms answer from local SQLite.
|
|
21
|
+
*
|
|
22
|
+
* **Topology**. Main thread spawns this worker once, holds a `WorkerBus` over
|
|
23
|
+
* the `parentPort`. The worker also holds a `WorkerBus` over `parentPort` and
|
|
24
|
+
* registers its handlers. Two ends of the same channel, mirror APIs.
|
|
25
|
+
*
|
|
26
|
+
* **DB connection**. `better-sqlite3` is in-process synchronous, so the
|
|
27
|
+
* worker opens its OWN connection to the same `.db` file. WAL mode (set in
|
|
28
|
+
* `MailxDB`) makes concurrent readers safe; writes still happen on the main
|
|
29
|
+
* thread for Phase 1 and the worker sees committed rows immediately.
|
|
30
|
+
* Phase 2 moves writes to the sync worker, which routes through this worker
|
|
31
|
+
* for any UI-visible mutation so the LRU/cache here can update in lock-step.
|
|
32
|
+
*
|
|
33
|
+
* **No mailparser here.** Body parsing is the parse-worker's job. This
|
|
34
|
+
* worker reads the .eml bytes off disk and delegates parsing via
|
|
35
|
+
* `parseSerial` (which already postMessages to its own worker). Two workers
|
|
36
|
+
* either side of this thread; the read coordinator is purely an indexer.
|
|
37
|
+
*/
|
|
38
|
+
import { parentPort } from "node:worker_threads";
|
|
39
|
+
import { WorkerBus } from "@bobfrankston/mailx-bus";
|
|
40
|
+
import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
|
|
41
|
+
import { LocalStore } from "./local-store.js";
|
|
42
|
+
if (!parentPort) {
|
|
43
|
+
throw new Error("db-worker: must be spawned as a worker, parentPort is null");
|
|
44
|
+
}
|
|
45
|
+
// Wait for the init message before constructing the DB — the main thread
|
|
46
|
+
// owns the resolved paths and passes them in.
|
|
47
|
+
let store = null;
|
|
48
|
+
let initialized = false;
|
|
49
|
+
parentPort.once("message", async (msg) => {
|
|
50
|
+
if (msg?.kind !== "init") {
|
|
51
|
+
parentPort.postMessage({ kind: "init-error", error: `expected "init" message, got ${JSON.stringify(msg)}` });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const db = new MailxDB(msg.dbPath);
|
|
56
|
+
const bodyStore = new FileMessageStore(msg.storePath);
|
|
57
|
+
store = new LocalStore(db, bodyStore);
|
|
58
|
+
initialized = true;
|
|
59
|
+
// Now that the store is up, register handlers and ack init.
|
|
60
|
+
registerHandlers(store);
|
|
61
|
+
parentPort.postMessage({ kind: "init-ok" });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
parentPort.postMessage({ kind: "init-error", error: e?.message || String(e) });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
function registerHandlers(s) {
|
|
68
|
+
// The bus speaks over `parentPort`. `WorkerBus` reads the same channel
|
|
69
|
+
// we just consumed `init` from — that's fine, `once()` removes the
|
|
70
|
+
// single-shot listener and the bus's own `on("message")` takes over.
|
|
71
|
+
const bus = new WorkerBus(parentPort);
|
|
72
|
+
// ── Folders ─────────────────────────────────────────────────────
|
|
73
|
+
bus.register("db:getFolders", ({ accountId }) => s.getFolders(accountId));
|
|
74
|
+
// ── Message list ────────────────────────────────────────────────
|
|
75
|
+
bus.register("db:getMessages", (q) => s.getMessages(q));
|
|
76
|
+
bus.register("db:getUnifiedInbox", ({ page = 1, pageSize = 50 }) => s.getUnifiedInbox(page, pageSize));
|
|
77
|
+
bus.register("db:searchMessages", ({ query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false }) => s.searchMessages(query, page, pageSize, accountId, folderId, includeTrashSpam));
|
|
78
|
+
// ── Single message (envelope + body) ────────────────────────────
|
|
79
|
+
bus.register("db:getMessage", ({ accountId, uid, allowRemote = false, folderId }) => s.getMessage(accountId, uid, allowRemote, folderId));
|
|
80
|
+
bus.register("db:getThreadMessages", ({ accountId, threadId }) => s.getThreadMessages?.(accountId, threadId) ?? []);
|
|
81
|
+
// ── Contacts read (lookups) ─────────────────────────────────────
|
|
82
|
+
bus.register("db:searchContacts", ({ query, limit }) => s.searchContacts?.(query, limit) ?? []);
|
|
83
|
+
// ── Accounts (read-only DB shape) ───────────────────────────────
|
|
84
|
+
bus.register("db:getAccounts", () => s.getAccounts());
|
|
85
|
+
// ── Config-cache invalidation ──────────────────────────────────
|
|
86
|
+
// Main thread receives `configChanged` events (allowlist.jsonc etc.)
|
|
87
|
+
// and publishes them; the worker invalidates its in-LocalStore caches
|
|
88
|
+
// so the next read sees the fresh JSONC.
|
|
89
|
+
bus.subscribe("config:changed", () => {
|
|
90
|
+
s.invalidateConfigCaches();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Defensive: if the bus is ever asked something before init completes, the
|
|
94
|
+
// caller gets a clear error rather than a hang. Mirrors the init-error path.
|
|
95
|
+
parentPort.on("message", (msg) => {
|
|
96
|
+
if (!initialized && msg && typeof msg === "object" && msg.kind === "request") {
|
|
97
|
+
parentPort.postMessage({
|
|
98
|
+
kind: "reply",
|
|
99
|
+
id: msg.id,
|
|
100
|
+
error: "db-worker: not initialized — main thread must send {kind:'init'} first",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
//# sourceMappingURL=db-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db-worker.js","sourceRoot":"","sources":["db-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB factor — Node worker thread.
|
|
3
|
+
*
|
|
4
|
+
* Owns the SQLite handle and FileMessageStore. Serves the UI-read surface
|
|
5
|
+
* (getFolders, getMessages, getUnifiedInbox, getMessage, searchMessages,
|
|
6
|
+
* searchContacts, getThreadMessages) over `mailx-bus`. Subscribes to write
|
|
7
|
+
* events from the sync factor (Phase 2) and emits `db:messageAdded` /
|
|
8
|
+
* `db:messageRemoved` / `db:flagChanged` so the UI can update incrementally
|
|
9
|
+
* without re-asking.
|
|
10
|
+
*
|
|
11
|
+
* **Why a worker.** Until now every UI click had to traverse the same Node
|
|
12
|
+
* event loop the sync code runs on. A 14-minute synchronous freeze in the
|
|
13
|
+
* sync path (Dovecot SELECT hang, Prefirst.OIA backfill loop) starved every
|
|
14
|
+
* UI read. The IPC pipe handler couldn't even be scheduled. From the user's
|
|
15
|
+
* perspective: Sent shows empty, message bodies never load, autocomplete
|
|
16
|
+
* dies, drafts can't save. All of those have one cause: the read path
|
|
17
|
+
* shares an event loop with the write/sync path.
|
|
18
|
+
*
|
|
19
|
+
* In this worker, the read path is on its own thread. Sync can freeze for
|
|
20
|
+
* an hour and the user's click still gets a 5 ms answer from local SQLite.
|
|
21
|
+
*
|
|
22
|
+
* **Topology**. Main thread spawns this worker once, holds a `WorkerBus` over
|
|
23
|
+
* the `parentPort`. The worker also holds a `WorkerBus` over `parentPort` and
|
|
24
|
+
* registers its handlers. Two ends of the same channel, mirror APIs.
|
|
25
|
+
*
|
|
26
|
+
* **DB connection**. `better-sqlite3` is in-process synchronous, so the
|
|
27
|
+
* worker opens its OWN connection to the same `.db` file. WAL mode (set in
|
|
28
|
+
* `MailxDB`) makes concurrent readers safe; writes still happen on the main
|
|
29
|
+
* thread for Phase 1 and the worker sees committed rows immediately.
|
|
30
|
+
* Phase 2 moves writes to the sync worker, which routes through this worker
|
|
31
|
+
* for any UI-visible mutation so the LRU/cache here can update in lock-step.
|
|
32
|
+
*
|
|
33
|
+
* **No mailparser here.** Body parsing is the parse-worker's job. This
|
|
34
|
+
* worker reads the .eml bytes off disk and delegates parsing via
|
|
35
|
+
* `parseSerial` (which already postMessages to its own worker). Two workers
|
|
36
|
+
* either side of this thread; the read coordinator is purely an indexer.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { parentPort } from "node:worker_threads";
|
|
40
|
+
import { WorkerBus, type MessagePortLike } from "@bobfrankston/mailx-bus";
|
|
41
|
+
import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
|
|
42
|
+
import { LocalStore } from "./local-store.js";
|
|
43
|
+
import type { MessageQuery } from "@bobfrankston/mailx-types";
|
|
44
|
+
|
|
45
|
+
if (!parentPort) {
|
|
46
|
+
throw new Error("db-worker: must be spawned as a worker, parentPort is null");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface InitMessage {
|
|
50
|
+
kind: "init";
|
|
51
|
+
dbPath: string;
|
|
52
|
+
storePath: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Wait for the init message before constructing the DB — the main thread
|
|
56
|
+
// owns the resolved paths and passes them in.
|
|
57
|
+
let store: LocalStore | null = null;
|
|
58
|
+
let initialized = false;
|
|
59
|
+
|
|
60
|
+
parentPort.once("message", async (msg: InitMessage) => {
|
|
61
|
+
if (msg?.kind !== "init") {
|
|
62
|
+
parentPort!.postMessage({ kind: "init-error", error: `expected "init" message, got ${JSON.stringify(msg)}` });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const db = new MailxDB(msg.dbPath);
|
|
67
|
+
const bodyStore = new FileMessageStore(msg.storePath);
|
|
68
|
+
store = new LocalStore(db, bodyStore);
|
|
69
|
+
initialized = true;
|
|
70
|
+
// Now that the store is up, register handlers and ack init.
|
|
71
|
+
registerHandlers(store);
|
|
72
|
+
parentPort!.postMessage({ kind: "init-ok" });
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
parentPort!.postMessage({ kind: "init-error", error: e?.message || String(e) });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function registerHandlers(s: LocalStore): void {
|
|
79
|
+
// The bus speaks over `parentPort`. `WorkerBus` reads the same channel
|
|
80
|
+
// we just consumed `init` from — that's fine, `once()` removes the
|
|
81
|
+
// single-shot listener and the bus's own `on("message")` takes over.
|
|
82
|
+
const bus = new WorkerBus(parentPort as unknown as MessagePortLike);
|
|
83
|
+
|
|
84
|
+
// ── Folders ─────────────────────────────────────────────────────
|
|
85
|
+
bus.register<{ accountId: string }, any[]>(
|
|
86
|
+
"db:getFolders",
|
|
87
|
+
({ accountId }) => s.getFolders(accountId),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// ── Message list ────────────────────────────────────────────────
|
|
91
|
+
bus.register<MessageQuery, any>(
|
|
92
|
+
"db:getMessages",
|
|
93
|
+
(q) => s.getMessages(q),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
bus.register<{ page?: number; pageSize?: number }, any>(
|
|
97
|
+
"db:getUnifiedInbox",
|
|
98
|
+
({ page = 1, pageSize = 50 }) => s.getUnifiedInbox(page, pageSize),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
bus.register<
|
|
102
|
+
{ query: string; page?: number; pageSize?: number; accountId?: string; folderId?: number; includeTrashSpam?: boolean },
|
|
103
|
+
any
|
|
104
|
+
>(
|
|
105
|
+
"db:searchMessages",
|
|
106
|
+
({ query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false }) =>
|
|
107
|
+
s.searchMessages(query, page, pageSize, accountId, folderId, includeTrashSpam),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// ── Single message (envelope + body) ────────────────────────────
|
|
111
|
+
bus.register<{ accountId: string; uid: number; allowRemote?: boolean; folderId?: number }, any>(
|
|
112
|
+
"db:getMessage",
|
|
113
|
+
({ accountId, uid, allowRemote = false, folderId }) =>
|
|
114
|
+
s.getMessage(accountId, uid, allowRemote, folderId),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
bus.register<{ accountId: string; threadId: string }, any[]>(
|
|
118
|
+
"db:getThreadMessages",
|
|
119
|
+
({ accountId, threadId }) => (s as any).getThreadMessages?.(accountId, threadId) ?? [],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// ── Contacts read (lookups) ─────────────────────────────────────
|
|
123
|
+
bus.register<{ query: string; limit?: number }, any[]>(
|
|
124
|
+
"db:searchContacts",
|
|
125
|
+
({ query, limit }) => (s as any).searchContacts?.(query, limit) ?? [],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── Accounts (read-only DB shape) ───────────────────────────────
|
|
129
|
+
bus.register<void, any[]>(
|
|
130
|
+
"db:getAccounts",
|
|
131
|
+
() => s.getAccounts(),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// ── Config-cache invalidation ──────────────────────────────────
|
|
135
|
+
// Main thread receives `configChanged` events (allowlist.jsonc etc.)
|
|
136
|
+
// and publishes them; the worker invalidates its in-LocalStore caches
|
|
137
|
+
// so the next read sees the fresh JSONC.
|
|
138
|
+
bus.subscribe<{ filename?: string }>("config:changed", () => {
|
|
139
|
+
s.invalidateConfigCaches();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Defensive: if the bus is ever asked something before init completes, the
|
|
144
|
+
// caller gets a clear error rather than a hang. Mirrors the init-error path.
|
|
145
|
+
parentPort.on("message", (msg: any) => {
|
|
146
|
+
if (!initialized && msg && typeof msg === "object" && msg.kind === "request") {
|
|
147
|
+
parentPort!.postMessage({
|
|
148
|
+
kind: "reply",
|
|
149
|
+
id: msg.id,
|
|
150
|
+
error: "db-worker: not initialized — main thread must send {kind:'init'} first",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@bobfrankston/mailx-bus": "file:../mailx-bus",
|
|
12
13
|
"@bobfrankston/mailx-imap": "file:../mailx-imap",
|
|
13
14
|
"@bobfrankston/mailx-settings": "file:../mailx-settings",
|
|
14
15
|
"@bobfrankston/mailx-store": "file:../mailx-store",
|