@bobfrankston/mailx-store 0.1.26 → 0.1.27
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 +3 -3
- package/parse-worker.js +35 -10
- package/store.d.ts +9 -0
- package/store.js +21 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.14",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.17",
|
|
14
14
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
15
15
|
"mailparser": "^3.7.2"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
".transformedSnapshot": {
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
32
|
+
"@bobfrankston/mailx-types": "^0.1.14",
|
|
33
33
|
"@bobfrankston/mailx-settings": "^0.1.17",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
package/parse-worker.js
CHANGED
|
@@ -21,18 +21,43 @@ import { simpleParser } from "mailparser";
|
|
|
21
21
|
if (!parentPort) {
|
|
22
22
|
throw new Error("parse-worker: must be spawned as a worker, parentPort is null");
|
|
23
23
|
}
|
|
24
|
-
// Self-warmup: parse a synthetic
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// Self-warmup: parse a synthetic message at worker startup so V8 JIT,
|
|
25
|
+
// libmime / iconv-lite loading, and mailparser's lazy init all complete
|
|
26
|
+
// *before* the worker accepts its first real request.
|
|
27
|
+
//
|
|
28
|
+
// CRITICAL (Bob 2026-05-15): the warmup message MUST exercise every heavy
|
|
29
|
+
// code path a real email hits, or the cold-start cost just moves to the
|
|
30
|
+
// first real parse. The previous warmup was a trivial text/plain message
|
|
31
|
+
// — it JIT'd the basic path but NOT the HTML pipeline, quoted-printable
|
|
32
|
+
// decode, multipart boundary handling, or attachment extraction. Result:
|
|
33
|
+
// the first real parse (a 96 KB multipart/alternative) took 33 SECONDS
|
|
34
|
+
// in the log — the cold start was never actually absorbed. This warmup
|
|
35
|
+
// message is multipart/mixed → multipart/alternative(text + QP-encoded
|
|
36
|
+
// HTML) + a base64 attachment, with HTML entities and a non-ASCII char,
|
|
37
|
+
// so the first real parse rides warm JIT for all of them.
|
|
30
38
|
const _warmupT0 = Date.now();
|
|
31
|
-
const
|
|
32
|
-
+ "Subject:
|
|
39
|
+
const _warmupMessage = "From: warmup@mailx.local\r\nTo: warmup@mailx.local\r\n"
|
|
40
|
+
+ "Subject: =?UTF-8?Q?warm=E2=80=91up?=\r\nMIME-Version: 1.0\r\n"
|
|
41
|
+
+ "Content-Type: multipart/mixed; boundary=\"MIX\"\r\n\r\n"
|
|
42
|
+
+ "--MIX\r\n"
|
|
43
|
+
+ "Content-Type: multipart/alternative; boundary=\"ALT\"\r\n\r\n"
|
|
44
|
+
+ "--ALT\r\n"
|
|
33
45
|
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\n"
|
|
34
|
-
+ "parse-worker cold-start absorber
|
|
35
|
-
|
|
46
|
+
+ "parse-worker cold-start absorber — discard.\r\n"
|
|
47
|
+
+ "--ALT\r\n"
|
|
48
|
+
+ "Content-Type: text/html; charset=UTF-8\r\n"
|
|
49
|
+
+ "Content-Transfer-Encoding: quoted-printable\r\n\r\n"
|
|
50
|
+
+ "<html><body><p>warm=E2=80=91up & discard</p>"
|
|
51
|
+
+ "<blockquote>quoted</blockquote><a href=3D\"http://x\">link</a></body></html>\r\n"
|
|
52
|
+
+ "--ALT--\r\n"
|
|
53
|
+
+ "--MIX\r\n"
|
|
54
|
+
+ "Content-Type: application/octet-stream; name=\"w.bin\"\r\n"
|
|
55
|
+
+ "Content-Transfer-Encoding: base64\r\n"
|
|
56
|
+
+ "Content-Disposition: attachment; filename=\"w.bin\"\r\n\r\n"
|
|
57
|
+
+ "d2FybXVw\r\n"
|
|
58
|
+
+ "--MIX--\r\n";
|
|
59
|
+
const _warmupPromise = simpleParser(Buffer.from(_warmupMessage, "utf8")).then(() => {
|
|
60
|
+
// Heartbeat so the main thread can log the absorbed cost.
|
|
36
61
|
parentPort.postMessage({ warmupMs: Date.now() - _warmupT0 });
|
|
37
62
|
}).catch(() => { });
|
|
38
63
|
parentPort.on("message", async (msg) => {
|
package/store.d.ts
CHANGED
|
@@ -41,6 +41,15 @@ export interface StoreMessage extends MessageEnvelope {
|
|
|
41
41
|
contentId: string;
|
|
42
42
|
}>;
|
|
43
43
|
cached: boolean;
|
|
44
|
+
/** Disambiguates `cached: false`:
|
|
45
|
+
* - bodyOnDisk false → the .eml is NOT on disk; caller should queue
|
|
46
|
+
* an IMAP/Gmail fetch.
|
|
47
|
+
* - bodyOnDisk true → the .eml IS on disk; a parse is in flight.
|
|
48
|
+
* Caller MUST NOT queue a fetch — just wait for the `bodyAvailable`
|
|
49
|
+
* bus event. Queuing a fetch here caused the 10-Hz "Loading body…"
|
|
50
|
+
* loop (Bob 2026-05-15): cached:false → MailxService fetched →
|
|
51
|
+
* reconciler re-published bodyAvailable → viewer re-called → repeat. */
|
|
52
|
+
bodyOnDisk: boolean;
|
|
44
53
|
deliveredTo: string;
|
|
45
54
|
returnPath: string;
|
|
46
55
|
listUnsubscribe: string;
|
package/store.js
CHANGED
|
@@ -222,11 +222,13 @@ export class Store {
|
|
|
222
222
|
hasRemoteContent: false, remoteAllowed: allowRemote,
|
|
223
223
|
attachments: [],
|
|
224
224
|
cached: false,
|
|
225
|
+
bodyOnDisk: false, // overridden to true on the parse-pending return
|
|
225
226
|
deliveredTo: "", returnPath: "",
|
|
226
227
|
listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
|
|
227
228
|
emlPath: "",
|
|
228
229
|
isFlagged,
|
|
229
230
|
};
|
|
231
|
+
// No body file → caller should queue a fetch (bodyOnDisk stays false).
|
|
230
232
|
if (!storedPath)
|
|
231
233
|
return empty;
|
|
232
234
|
if (!await this.bodyStore.hasByPath(storedPath))
|
|
@@ -246,25 +248,22 @@ export class Store {
|
|
|
246
248
|
// body cached; recompute the volatile fields and overlay.
|
|
247
249
|
return { ...cached, remoteAllowed: allowRemote, isFlagged };
|
|
248
250
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
251
|
+
// Synchronous parse. The .eml is on disk; read + parse it inline
|
|
252
|
+
// and return the fully-rendered message. The parse runs on the
|
|
253
|
+
// parse-worker thread (off the main event loop) — the IPC promise
|
|
254
|
+
// just awaits the worker's reply, ~50-200 ms on a warm worker.
|
|
255
|
+
//
|
|
256
|
+
// 2026-05-15: this REPLACED an async "return envelope now, parse
|
|
257
|
+
// later, emit bodyAvailable" split. That split overloaded
|
|
258
|
+
// `cached:false` (was it "no body file" or "parse pending"?),
|
|
259
|
+
// which made MailxService queue redundant fetches, which churned
|
|
260
|
+
// the .eml mtime, which invalidated the cache key, which spawned
|
|
261
|
+
// duplicate parses — a 10 Hz "Loading body…" loop and 30-second
|
|
262
|
+
// queue-contention parses. Synchronous parse makes the whole
|
|
263
|
+
// class structurally impossible: getMessage either returns the
|
|
264
|
+
// body or returns `cached:false` meaning exactly "fetch it."
|
|
265
|
+
const raw = await this.bodyStore.readByPath(storedPath);
|
|
263
266
|
const adjusted = sniffAndFixCharset(raw);
|
|
264
|
-
// simpleParser blocks the event loop while it parses — visible cost
|
|
265
|
-
// on >100 KB messages. Time it so the log makes the cost concrete:
|
|
266
|
-
// when this number is high, that's why other IPC calls (the next
|
|
267
|
-
// message click, the folder-count update tick) stall.
|
|
268
267
|
const _parseT0 = Date.now();
|
|
269
268
|
const parsed = await parseSerial(adjusted);
|
|
270
269
|
const _parseMs = Date.now() - _parseT0;
|
|
@@ -367,19 +366,10 @@ export class Store {
|
|
|
367
366
|
emlPath: this.bodyStore.absolutePath(storedPath),
|
|
368
367
|
isFlagged,
|
|
369
368
|
};
|
|
370
|
-
// Memoize
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
// BUT don't poison the cache with an empty parse — a malformed .eml
|
|
376
|
-
// or a fetch that left a stub file gives bodyHtml === "" AND
|
|
377
|
-
// bodyText === ""; caching that means future views serve emptiness
|
|
378
|
-
// until the daemon restarts. Let the next view try again; the
|
|
379
|
-
// parse cost on a 9 kB .eml is ~20 ms, so re-parsing an oddity
|
|
380
|
-
// is cheap.
|
|
381
|
-
const hasContent = (bodyHtml && bodyHtml.length > 0) || (bodyText && bodyText.length > 0);
|
|
382
|
-
if (mtimeMs > 0 && hasContent)
|
|
369
|
+
// Memoize for instant re-view of the same message. cacheKey is
|
|
370
|
+
// path+mtime so a re-fetch (mtime changes) invalidates naturally.
|
|
371
|
+
// The LRU is a perf tier only — correctness never depends on it.
|
|
372
|
+
if (mtimeMs > 0)
|
|
383
373
|
this.parsedLruPut(cacheKey, result);
|
|
384
374
|
return result;
|
|
385
375
|
}
|