@bobfrankston/rmfmail 1.0.705 → 1.0.706
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/bin/mailx.js +15 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +17 -1
- package/client/app.bundle.js +12 -0
- package/client/app.bundle.js.map +2 -2
- package/client/compose/compose.bundle.js +32 -4
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/spellcheck.js +34 -5
- package/client/compose/spellcheck.js.map +1 -1
- package/client/compose/spellcheck.ts +31 -4
- package/client/lib/api-client.js +9 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +9 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +3 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +21 -1
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +23 -1
- package/packages/mailx-service/jsonrpc.js +6 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +6 -0
- package/packages/mailx-settings/index.d.ts +7 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +24 -0
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +25 -0
- package/packages/mailx-store/index.d.ts +1 -1
- package/packages/mailx-store/index.d.ts.map +1 -1
- package/packages/mailx-store/index.js +1 -1
- package/packages/mailx-store/index.js.map +1 -1
- package/packages/mailx-store/index.ts +1 -1
- package/packages/mailx-store/parse-serial.d.ts +40 -24
- package/packages/mailx-store/parse-serial.d.ts.map +1 -1
- package/packages/mailx-store/parse-serial.js +161 -29
- package/packages/mailx-store/parse-serial.js.map +1 -1
- package/packages/mailx-store/parse-serial.ts +151 -37
- package/packages/mailx-store/parse-worker.d.ts +2 -0
- package/packages/mailx-store/parse-worker.d.ts.map +1 -0
- package/packages/mailx-store/parse-worker.js +53 -0
- package/packages/mailx-store/parse-worker.js.map +1 -0
- package/packages/mailx-store/parse-worker.ts +62 -0
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-34984 → node_modules.npmglobalize-stash-44928}/.package-lock.json +0 -0
|
@@ -1,40 +1,111 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Worker-thread-backed simpleParser dispatcher.
|
|
3
3
|
*
|
|
4
4
|
* mailparser's `simpleParser` is declared `async` but its work is CPU-bound
|
|
5
|
-
* (Node Streams + libmime decoding). When
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* (Node Streams + libmime decoding). When it runs on the main event loop:
|
|
6
|
+
* - The first parse after a fresh process takes 14-25 seconds (cold V8
|
|
7
|
+
* JIT + libmime + iconv-lite + charset-table loading). See log
|
|
8
|
+
* 2026-05-14 01:11:44 — simpleParser 14524ms for 5 KB.
|
|
9
|
+
* - Every parse, cold or warm, blocks the IPC stdin pump for its
|
|
10
|
+
* duration. A 200 ms parse delays every queued IPC message by 200 ms.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Moving the parse to a `worker_threads` Worker fixes both problems:
|
|
13
|
+
* - The main event loop stays responsive — IPC, sync, timers all run
|
|
14
|
+
* during the parse. Click-to-render latency is bounded by post-message
|
|
15
|
+
* RTT (sub-ms) + parse time on the worker, but the rest of the app
|
|
16
|
+
* stays alive.
|
|
17
|
+
* - The worker absorbs cold-start once. Subsequent parses ride the
|
|
18
|
+
* worker's warm JIT and run in their natural 50-500 ms budget.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* Priority queue: foreground (UI clicks) jumps over background (sync /
|
|
21
|
+
* prefetch) when more than one parse is pending. Each parse still runs
|
|
22
|
+
* sequentially on the worker — the single-worker design intentionally
|
|
23
|
+
* mirrors the prior in-process serialization to keep CPU bounded.
|
|
19
24
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
25
|
+
* Lazy spawn: the worker is created on first call. boot/setup code that
|
|
26
|
+
* runs no parses pays nothing.
|
|
27
|
+
*
|
|
28
|
+
* Worker lifecycle: the worker is process-singleton, never explicitly
|
|
29
|
+
* terminated. Node exits cleanly because the worker is `unref()`d so it
|
|
30
|
+
* doesn't keep the event loop alive.
|
|
24
31
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* Lives in mailx-store rather than mailx-service so both the UI-hot path
|
|
33
|
+
* (mailx-service/local-store.ts) and the sync path (mailx-imap) share one
|
|
34
|
+
* worker — otherwise each module would spawn its own (parallel workers
|
|
35
|
+
* would defeat the bound on CPU, and each pays its own cold start).
|
|
28
36
|
*/
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
import { Worker } from "node:worker_threads";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
const _head = [];
|
|
41
|
+
const _tail = [];
|
|
32
42
|
let _pumping = false;
|
|
33
|
-
|
|
43
|
+
let _worker = null;
|
|
44
|
+
let _nextId = 1;
|
|
45
|
+
const _inflight = new Map();
|
|
46
|
+
function getWorker() {
|
|
47
|
+
if (_worker)
|
|
48
|
+
return _worker;
|
|
49
|
+
// Resolve parse-worker.js alongside the compiled parse-serial.js.
|
|
50
|
+
// import.meta.url is the running .js URL after tsc compilation, so
|
|
51
|
+
// the worker resolves cleanly from the package install location.
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const workerPath = join(here, "parse-worker.js");
|
|
54
|
+
const w = new Worker(workerPath);
|
|
55
|
+
w.on("message", (msg) => {
|
|
56
|
+
// Warmup heartbeat (no `id`). Logs the worker cold-start cost so
|
|
57
|
+
// we can confirm the absorber is working on each boot.
|
|
58
|
+
if (typeof msg.warmupMs === "number") {
|
|
59
|
+
console.log(` [parse-worker] cold-start absorbed in worker in ${msg.warmupMs}ms`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (typeof msg.id !== "number")
|
|
63
|
+
return;
|
|
64
|
+
const entry = _inflight.get(msg.id);
|
|
65
|
+
if (!entry)
|
|
66
|
+
return;
|
|
67
|
+
_inflight.delete(msg.id);
|
|
68
|
+
if (msg.ok)
|
|
69
|
+
entry.resolve(msg.result);
|
|
70
|
+
else
|
|
71
|
+
entry.reject(new Error(msg.error || "parse-worker error"));
|
|
72
|
+
signalCompletion();
|
|
73
|
+
});
|
|
74
|
+
w.on("error", (e) => {
|
|
75
|
+
// Fatal worker crash — fail every in-flight parse, then null the
|
|
76
|
+
// singleton so the next call respawns.
|
|
77
|
+
for (const entry of _inflight.values())
|
|
78
|
+
entry.reject(e);
|
|
79
|
+
_inflight.clear();
|
|
80
|
+
_worker = null;
|
|
81
|
+
signalCompletion();
|
|
82
|
+
});
|
|
83
|
+
w.on("exit", (code) => {
|
|
84
|
+
if (_inflight.size > 0) {
|
|
85
|
+
const err = new Error(`parse-worker exited (code=${code}) with ${_inflight.size} in-flight`);
|
|
86
|
+
for (const entry of _inflight.values())
|
|
87
|
+
entry.reject(err);
|
|
88
|
+
_inflight.clear();
|
|
89
|
+
}
|
|
90
|
+
_worker = null;
|
|
91
|
+
signalCompletion();
|
|
92
|
+
});
|
|
93
|
+
// Don't let the worker prevent process exit.
|
|
94
|
+
w.unref();
|
|
95
|
+
_worker = w;
|
|
96
|
+
return w;
|
|
97
|
+
}
|
|
98
|
+
/** In-main-thread fallback. Used when the worker spawn itself fails (e.g.,
|
|
99
|
+
* a packaged build where parse-worker.js can't be resolved). Falls back
|
|
100
|
+
* to the original behavior — blocks the event loop, but at least works.
|
|
101
|
+
* Lazy-imports mailparser so the main thread doesn't pay the (heavy)
|
|
102
|
+
* module-init cost unless the fallback actually fires. */
|
|
103
|
+
async function pumpInMain() {
|
|
34
104
|
if (_pumping)
|
|
35
105
|
return;
|
|
36
106
|
_pumping = true;
|
|
37
107
|
try {
|
|
108
|
+
const { simpleParser } = await import("mailparser");
|
|
38
109
|
for (;;) {
|
|
39
110
|
const next = _head.shift() ?? _tail.shift();
|
|
40
111
|
if (!next)
|
|
@@ -52,17 +123,78 @@ async function pump() {
|
|
|
52
123
|
_pumping = false;
|
|
53
124
|
}
|
|
54
125
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
126
|
+
// Notifier the pump awaits to drive the next iteration. When a reply
|
|
127
|
+
// arrives from the worker, completing the in-flight parse, this fires
|
|
128
|
+
// and unblocks the pump so it can pop the next queued parse. Avoids the
|
|
129
|
+
// busy-wait setTimeout-poll version.
|
|
130
|
+
let _completionSignal = null;
|
|
131
|
+
function signalCompletion() {
|
|
132
|
+
const fn = _completionSignal;
|
|
133
|
+
_completionSignal = null;
|
|
134
|
+
if (fn)
|
|
135
|
+
fn();
|
|
136
|
+
}
|
|
137
|
+
async function pumpViaWorker() {
|
|
138
|
+
if (_pumping)
|
|
139
|
+
return;
|
|
140
|
+
_pumping = true;
|
|
141
|
+
try {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const next = _head.shift() ?? _tail.shift();
|
|
144
|
+
if (!next)
|
|
145
|
+
break;
|
|
146
|
+
_inflight.set(next.id, next);
|
|
147
|
+
try {
|
|
148
|
+
getWorker().postMessage({ id: next.id, source: next.source });
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
_inflight.delete(next.id);
|
|
152
|
+
next.reject(e);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Wait for THIS parse to complete before sending the next.
|
|
156
|
+
// Preserves the priority order — a foreground entry that arrives
|
|
157
|
+
// after we've already postMessage'd a background parse will be
|
|
158
|
+
// next in line, NOT after several already-queued backgrounds.
|
|
159
|
+
await new Promise(resolve => { _completionSignal = resolve; });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
_pumping = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Spawn the parse worker early so its cold-start (mailparser module
|
|
167
|
+
* loading, V8 JIT, libmime / iconv-lite tables — empirically 14-25 s)
|
|
168
|
+
* runs in parallel with the rest of boot. Safe to call multiple times;
|
|
169
|
+
* the worker is a process-singleton. Returns immediately; the actual
|
|
170
|
+
* cold-start completes in the worker's internal self-warm. */
|
|
171
|
+
export function prewarmParseWorker() {
|
|
172
|
+
try {
|
|
173
|
+
getWorker();
|
|
174
|
+
}
|
|
175
|
+
catch { /* fallback path handles it on first parseSerial */ }
|
|
176
|
+
}
|
|
177
|
+
/** Serialized wrapper around `mailparser.simpleParser` running in a worker
|
|
178
|
+
* thread. `priority` defaults to "foreground" — only sync/background
|
|
179
|
+
* callers should pass "background" so user clicks aren't stuck behind a
|
|
180
|
+
* back-fill. If the worker can't be spawned (rare), falls back to an
|
|
181
|
+
* in-main-thread serial pump so the system still functions. */
|
|
58
182
|
export async function parseSerial(source, priority = "foreground") {
|
|
59
183
|
return new Promise((resolve, reject) => {
|
|
60
|
-
const entry = { source, resolve, reject };
|
|
184
|
+
const entry = { id: _nextId++, source, resolve, reject };
|
|
61
185
|
if (priority === "foreground")
|
|
62
186
|
_head.push(entry);
|
|
63
187
|
else
|
|
64
188
|
_tail.push(entry);
|
|
65
|
-
|
|
189
|
+
// Try the worker first; fall back transparently if the spawn fails.
|
|
190
|
+
try {
|
|
191
|
+
getWorker();
|
|
192
|
+
pumpViaWorker();
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
console.error(` [parse-serial] worker unavailable, falling back to main-thread parse: ${e instanceof Error ? e.message : String(e)}`);
|
|
196
|
+
pumpInMain();
|
|
197
|
+
}
|
|
66
198
|
});
|
|
67
199
|
}
|
|
68
200
|
//# sourceMappingURL=parse-serial.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse-serial.js","sourceRoot":"","sources":["parse-serial.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"parse-serial.js","sourceRoot":"","sources":["parse-serial.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,55 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Worker-thread-backed simpleParser dispatcher.
|
|
3
3
|
*
|
|
4
4
|
* mailparser's `simpleParser` is declared `async` but its work is CPU-bound
|
|
5
|
-
* (Node Streams + libmime decoding). When
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* (Node Streams + libmime decoding). When it runs on the main event loop:
|
|
6
|
+
* - The first parse after a fresh process takes 14-25 seconds (cold V8
|
|
7
|
+
* JIT + libmime + iconv-lite + charset-table loading). See log
|
|
8
|
+
* 2026-05-14 01:11:44 — simpleParser 14524ms for 5 KB.
|
|
9
|
+
* - Every parse, cold or warm, blocks the IPC stdin pump for its
|
|
10
|
+
* duration. A 200 ms parse delays every queued IPC message by 200 ms.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Moving the parse to a `worker_threads` Worker fixes both problems:
|
|
13
|
+
* - The main event loop stays responsive — IPC, sync, timers all run
|
|
14
|
+
* during the parse. Click-to-render latency is bounded by post-message
|
|
15
|
+
* RTT (sub-ms) + parse time on the worker, but the rest of the app
|
|
16
|
+
* stays alive.
|
|
17
|
+
* - The worker absorbs cold-start once. Subsequent parses ride the
|
|
18
|
+
* worker's warm JIT and run in their natural 50-500 ms budget.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* Priority queue: foreground (UI clicks) jumps over background (sync /
|
|
21
|
+
* prefetch) when more than one parse is pending. Each parse still runs
|
|
22
|
+
* sequentially on the worker — the single-worker design intentionally
|
|
23
|
+
* mirrors the prior in-process serialization to keep CPU bounded.
|
|
19
24
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
25
|
+
* Lazy spawn: the worker is created on first call. boot/setup code that
|
|
26
|
+
* runs no parses pays nothing.
|
|
27
|
+
*
|
|
28
|
+
* Worker lifecycle: the worker is process-singleton, never explicitly
|
|
29
|
+
* terminated. Node exits cleanly because the worker is `unref()`d so it
|
|
30
|
+
* doesn't keep the event loop alive.
|
|
24
31
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* Lives in mailx-store rather than mailx-service so both the UI-hot path
|
|
33
|
+
* (mailx-service/local-store.ts) and the sync path (mailx-imap) share one
|
|
34
|
+
* worker — otherwise each module would spawn its own (parallel workers
|
|
35
|
+
* would defeat the bound on CPU, and each pays its own cold start).
|
|
28
36
|
*/
|
|
29
37
|
|
|
30
|
-
import {
|
|
38
|
+
import { Worker } from "node:worker_threads";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import type { ParsedMail, Source } from "mailparser";
|
|
31
42
|
|
|
32
|
-
/**
|
|
33
|
-
* Priority queue. A background pump runs the next pending parse; UI calls
|
|
34
|
-
* insert at the head of the line, sync calls go to the tail. Without this,
|
|
35
|
-
* a first-sync of 96 folders × thousands of messages each can queue
|
|
36
|
-
* thousands of `extractPreview` parses ahead of a single user click — the
|
|
37
|
-
* preview pane sits on "Loading body…" for minutes while the back-fill
|
|
38
|
-
* grinds through. Same parses, different order.
|
|
39
|
-
*/
|
|
40
43
|
type Pending = {
|
|
44
|
+
id: number;
|
|
41
45
|
source: Source;
|
|
42
46
|
resolve: (m: ParsedMail) => void;
|
|
43
47
|
reject: (e: unknown) => void;
|
|
44
48
|
};
|
|
45
|
-
const _head: Pending[] = [];
|
|
46
|
-
const _tail: Pending[] = [];
|
|
49
|
+
const _head: Pending[] = [];
|
|
50
|
+
const _tail: Pending[] = [];
|
|
47
51
|
let _pumping = false;
|
|
52
|
+
let _worker: Worker | null = null;
|
|
53
|
+
let _nextId = 1;
|
|
54
|
+
const _inflight = new Map<number, Pending>();
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
function getWorker(): Worker {
|
|
57
|
+
if (_worker) return _worker;
|
|
58
|
+
// Resolve parse-worker.js alongside the compiled parse-serial.js.
|
|
59
|
+
// import.meta.url is the running .js URL after tsc compilation, so
|
|
60
|
+
// the worker resolves cleanly from the package install location.
|
|
61
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const workerPath = join(here, "parse-worker.js");
|
|
63
|
+
const w = new Worker(workerPath);
|
|
64
|
+
w.on("message", (msg: { id?: number; ok?: boolean; result?: ParsedMail; error?: string; warmupMs?: number }) => {
|
|
65
|
+
// Warmup heartbeat (no `id`). Logs the worker cold-start cost so
|
|
66
|
+
// we can confirm the absorber is working on each boot.
|
|
67
|
+
if (typeof msg.warmupMs === "number") {
|
|
68
|
+
console.log(` [parse-worker] cold-start absorbed in worker in ${msg.warmupMs}ms`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (typeof msg.id !== "number") return;
|
|
72
|
+
const entry = _inflight.get(msg.id);
|
|
73
|
+
if (!entry) return;
|
|
74
|
+
_inflight.delete(msg.id);
|
|
75
|
+
if (msg.ok) entry.resolve(msg.result as ParsedMail);
|
|
76
|
+
else entry.reject(new Error(msg.error || "parse-worker error"));
|
|
77
|
+
signalCompletion();
|
|
78
|
+
});
|
|
79
|
+
w.on("error", (e: Error) => {
|
|
80
|
+
// Fatal worker crash — fail every in-flight parse, then null the
|
|
81
|
+
// singleton so the next call respawns.
|
|
82
|
+
for (const entry of _inflight.values()) entry.reject(e);
|
|
83
|
+
_inflight.clear();
|
|
84
|
+
_worker = null;
|
|
85
|
+
signalCompletion();
|
|
86
|
+
});
|
|
87
|
+
w.on("exit", (code: number) => {
|
|
88
|
+
if (_inflight.size > 0) {
|
|
89
|
+
const err = new Error(`parse-worker exited (code=${code}) with ${_inflight.size} in-flight`);
|
|
90
|
+
for (const entry of _inflight.values()) entry.reject(err);
|
|
91
|
+
_inflight.clear();
|
|
92
|
+
}
|
|
93
|
+
_worker = null;
|
|
94
|
+
signalCompletion();
|
|
95
|
+
});
|
|
96
|
+
// Don't let the worker prevent process exit.
|
|
97
|
+
w.unref();
|
|
98
|
+
_worker = w;
|
|
99
|
+
return w;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** In-main-thread fallback. Used when the worker spawn itself fails (e.g.,
|
|
103
|
+
* a packaged build where parse-worker.js can't be resolved). Falls back
|
|
104
|
+
* to the original behavior — blocks the event loop, but at least works.
|
|
105
|
+
* Lazy-imports mailparser so the main thread doesn't pay the (heavy)
|
|
106
|
+
* module-init cost unless the fallback actually fires. */
|
|
107
|
+
async function pumpInMain(): Promise<void> {
|
|
50
108
|
if (_pumping) return;
|
|
51
109
|
_pumping = true;
|
|
52
110
|
try {
|
|
111
|
+
const { simpleParser } = await import("mailparser");
|
|
53
112
|
for (;;) {
|
|
54
113
|
const next = _head.shift() ?? _tail.shift();
|
|
55
114
|
if (!next) break;
|
|
@@ -65,17 +124,72 @@ async function pump(): Promise<void> {
|
|
|
65
124
|
}
|
|
66
125
|
}
|
|
67
126
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
127
|
+
// Notifier the pump awaits to drive the next iteration. When a reply
|
|
128
|
+
// arrives from the worker, completing the in-flight parse, this fires
|
|
129
|
+
// and unblocks the pump so it can pop the next queued parse. Avoids the
|
|
130
|
+
// busy-wait setTimeout-poll version.
|
|
131
|
+
let _completionSignal: (() => void) | null = null;
|
|
132
|
+
function signalCompletion(): void {
|
|
133
|
+
const fn = _completionSignal;
|
|
134
|
+
_completionSignal = null;
|
|
135
|
+
if (fn) fn();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pumpViaWorker(): Promise<void> {
|
|
139
|
+
if (_pumping) return;
|
|
140
|
+
_pumping = true;
|
|
141
|
+
try {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const next = _head.shift() ?? _tail.shift();
|
|
144
|
+
if (!next) break;
|
|
145
|
+
_inflight.set(next.id, next);
|
|
146
|
+
try {
|
|
147
|
+
getWorker().postMessage({ id: next.id, source: next.source });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
_inflight.delete(next.id);
|
|
150
|
+
next.reject(e);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Wait for THIS parse to complete before sending the next.
|
|
154
|
+
// Preserves the priority order — a foreground entry that arrives
|
|
155
|
+
// after we've already postMessage'd a background parse will be
|
|
156
|
+
// next in line, NOT after several already-queued backgrounds.
|
|
157
|
+
await new Promise<void>(resolve => { _completionSignal = resolve; });
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
_pumping = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Spawn the parse worker early so its cold-start (mailparser module
|
|
165
|
+
* loading, V8 JIT, libmime / iconv-lite tables — empirically 14-25 s)
|
|
166
|
+
* runs in parallel with the rest of boot. Safe to call multiple times;
|
|
167
|
+
* the worker is a process-singleton. Returns immediately; the actual
|
|
168
|
+
* cold-start completes in the worker's internal self-warm. */
|
|
169
|
+
export function prewarmParseWorker(): void {
|
|
170
|
+
try { getWorker(); } catch { /* fallback path handles it on first parseSerial */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Serialized wrapper around `mailparser.simpleParser` running in a worker
|
|
174
|
+
* thread. `priority` defaults to "foreground" — only sync/background
|
|
175
|
+
* callers should pass "background" so user clicks aren't stuck behind a
|
|
176
|
+
* back-fill. If the worker can't be spawned (rare), falls back to an
|
|
177
|
+
* in-main-thread serial pump so the system still functions. */
|
|
71
178
|
export async function parseSerial(
|
|
72
179
|
source: Source,
|
|
73
180
|
priority: "foreground" | "background" = "foreground",
|
|
74
181
|
): Promise<ParsedMail> {
|
|
75
182
|
return new Promise<ParsedMail>((resolve, reject) => {
|
|
76
|
-
const entry: Pending = { source, resolve, reject };
|
|
183
|
+
const entry: Pending = { id: _nextId++, source, resolve, reject };
|
|
77
184
|
if (priority === "foreground") _head.push(entry);
|
|
78
185
|
else _tail.push(entry);
|
|
79
|
-
|
|
186
|
+
// Try the worker first; fall back transparently if the spawn fails.
|
|
187
|
+
try {
|
|
188
|
+
getWorker();
|
|
189
|
+
pumpViaWorker();
|
|
190
|
+
} catch (e: unknown) {
|
|
191
|
+
console.error(` [parse-serial] worker unavailable, falling back to main-thread parse: ${e instanceof Error ? e.message : String(e)}`);
|
|
192
|
+
pumpInMain();
|
|
193
|
+
}
|
|
80
194
|
});
|
|
81
195
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-worker.d.ts","sourceRoot":"","sources":["parse-worker.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailparser worker thread.
|
|
3
|
+
*
|
|
4
|
+
* Runs simpleParser off the main event loop so a 14-25 second cold-start
|
|
5
|
+
* (or any slow parse) can't block IPC. The main thread's `parse-serial.ts`
|
|
6
|
+
* spawns this worker once at first use, then dispatches every parse here
|
|
7
|
+
* via postMessage and awaits the reply.
|
|
8
|
+
*
|
|
9
|
+
* Protocol:
|
|
10
|
+
* main → worker: { id: number, source: Buffer | string }
|
|
11
|
+
* worker → main: { id: number, ok: true, result: ParsedMail }
|
|
12
|
+
* | { id: number, ok: false, error: string }
|
|
13
|
+
*
|
|
14
|
+
* The worker holds its own mailparser module instance — JIT cost and
|
|
15
|
+
* libmime/iconv-lite loading happen exactly once per worker process. The
|
|
16
|
+
* main thread sees only the postMessage round-trip (~1 ms on local
|
|
17
|
+
* structured-clone of a small Buffer + parsed object).
|
|
18
|
+
*/
|
|
19
|
+
import { parentPort } from "node:worker_threads";
|
|
20
|
+
import { simpleParser } from "mailparser";
|
|
21
|
+
if (!parentPort) {
|
|
22
|
+
throw new Error("parse-worker: must be spawned as a worker, parentPort is null");
|
|
23
|
+
}
|
|
24
|
+
// Self-warmup: parse a synthetic RFC 5322 message at worker startup so V8
|
|
25
|
+
// JIT, libmime / iconv-lite module loading, and mailparser's lazy
|
|
26
|
+
// initialisation all complete *before* the worker accepts its first real
|
|
27
|
+
// request. Sub-50 ms parses become the norm even on the first user click,
|
|
28
|
+
// instead of the 14-25 s cold-start observed when this work happened
|
|
29
|
+
// on-demand. Buffered messages received during warmup queue behind it.
|
|
30
|
+
const _warmupT0 = Date.now();
|
|
31
|
+
const _warmupPromise = simpleParser(Buffer.from("From: warmup@mailx.local\r\nTo: warmup@mailx.local\r\n"
|
|
32
|
+
+ "Subject: warmup\r\nMIME-Version: 1.0\r\n"
|
|
33
|
+
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\n"
|
|
34
|
+
+ "parse-worker cold-start absorber. Discard.\r\n", "utf8")).then(() => {
|
|
35
|
+
// Optional: emit a heartbeat so the main thread can log the cost.
|
|
36
|
+
parentPort.postMessage({ warmupMs: Date.now() - _warmupT0 });
|
|
37
|
+
}).catch(() => { });
|
|
38
|
+
parentPort.on("message", async (msg) => {
|
|
39
|
+
const { id, source } = msg;
|
|
40
|
+
// If a real parse arrives during warmup, queue behind it. The await
|
|
41
|
+
// resolves immediately once warmup is done; trivially fast on hot
|
|
42
|
+
// worker since _warmupPromise is already resolved.
|
|
43
|
+
await _warmupPromise;
|
|
44
|
+
try {
|
|
45
|
+
const result = await simpleParser(source);
|
|
46
|
+
parentPort.postMessage({ id, ok: true, result });
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
50
|
+
parentPort.postMessage({ id, ok: false, error });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
//# sourceMappingURL=parse-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-worker.js","sourceRoot":"","sources":["parse-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAe,MAAM,YAAY,CAAC;AAEvD,IAAI,CAAC,UAAU,EAAE,CAAC;IACd,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;AACrF,CAAC;AAOD,0EAA0E;AAC1E,kEAAkE;AAClE,yEAAyE;AACzE,0EAA0E;AAC1E,qEAAqE;AACrE,uEAAuE;AACvE,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;AAC7B,MAAM,cAAc,GAAG,YAAY,CAAC,MAAM,CAAC,IAAI,CAC3C,wDAAwD;MACtD,0CAA0C;MAC1C,iDAAiD;MACjD,gDAAgD,EAClD,MAAM,CACT,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE;IACT,kEAAkE;IAClE,UAAW,CAAC,WAAW,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAA8D,CAAC,CAAC,CAAC;AAE/E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAiB,EAAE,EAAE;IACjD,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC;IAC3B,oEAAoE;IACpE,kEAAkE;IAClE,mDAAmD;IACnD,MAAM,cAAc,CAAC;IACrB,IAAI,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,CAAC;QAC1C,UAAW,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IACtD,CAAC;IAAC,OAAO,CAAU,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACzD,UAAW,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IACtD,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailparser worker thread.
|
|
3
|
+
*
|
|
4
|
+
* Runs simpleParser off the main event loop so a 14-25 second cold-start
|
|
5
|
+
* (or any slow parse) can't block IPC. The main thread's `parse-serial.ts`
|
|
6
|
+
* spawns this worker once at first use, then dispatches every parse here
|
|
7
|
+
* via postMessage and awaits the reply.
|
|
8
|
+
*
|
|
9
|
+
* Protocol:
|
|
10
|
+
* main → worker: { id: number, source: Buffer | string }
|
|
11
|
+
* worker → main: { id: number, ok: true, result: ParsedMail }
|
|
12
|
+
* | { id: number, ok: false, error: string }
|
|
13
|
+
*
|
|
14
|
+
* The worker holds its own mailparser module instance — JIT cost and
|
|
15
|
+
* libmime/iconv-lite loading happen exactly once per worker process. The
|
|
16
|
+
* main thread sees only the postMessage round-trip (~1 ms on local
|
|
17
|
+
* structured-clone of a small Buffer + parsed object).
|
|
18
|
+
*/
|
|
19
|
+
import { parentPort } from "node:worker_threads";
|
|
20
|
+
import { simpleParser, type Source } from "mailparser";
|
|
21
|
+
|
|
22
|
+
if (!parentPort) {
|
|
23
|
+
throw new Error("parse-worker: must be spawned as a worker, parentPort is null");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ParseRequest {
|
|
27
|
+
id: number;
|
|
28
|
+
source: Source;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Self-warmup: parse a synthetic RFC 5322 message at worker startup so V8
|
|
32
|
+
// JIT, libmime / iconv-lite module loading, and mailparser's lazy
|
|
33
|
+
// initialisation all complete *before* the worker accepts its first real
|
|
34
|
+
// request. Sub-50 ms parses become the norm even on the first user click,
|
|
35
|
+
// instead of the 14-25 s cold-start observed when this work happened
|
|
36
|
+
// on-demand. Buffered messages received during warmup queue behind it.
|
|
37
|
+
const _warmupT0 = Date.now();
|
|
38
|
+
const _warmupPromise = simpleParser(Buffer.from(
|
|
39
|
+
"From: warmup@mailx.local\r\nTo: warmup@mailx.local\r\n"
|
|
40
|
+
+ "Subject: warmup\r\nMIME-Version: 1.0\r\n"
|
|
41
|
+
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\n"
|
|
42
|
+
+ "parse-worker cold-start absorber. Discard.\r\n",
|
|
43
|
+
"utf8",
|
|
44
|
+
)).then(() => {
|
|
45
|
+
// Optional: emit a heartbeat so the main thread can log the cost.
|
|
46
|
+
parentPort!.postMessage({ warmupMs: Date.now() - _warmupT0 });
|
|
47
|
+
}).catch(() => { /* warmup is best-effort; fall through to real handling */ });
|
|
48
|
+
|
|
49
|
+
parentPort.on("message", async (msg: ParseRequest) => {
|
|
50
|
+
const { id, source } = msg;
|
|
51
|
+
// If a real parse arrives during warmup, queue behind it. The await
|
|
52
|
+
// resolves immediately once warmup is done; trivially fast on hot
|
|
53
|
+
// worker since _warmupPromise is already resolved.
|
|
54
|
+
await _warmupPromise;
|
|
55
|
+
try {
|
|
56
|
+
const result = await simpleParser(source);
|
|
57
|
+
parentPort!.postMessage({ id, ok: true, result });
|
|
58
|
+
} catch (e: unknown) {
|
|
59
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
60
|
+
parentPort!.postMessage({ id, ok: false, error });
|
|
61
|
+
}
|
|
62
|
+
});
|