@bobfrankston/mailx-store 0.1.24 → 0.1.25

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/bus.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * StoreBus — typed pub/sub for Store events.
3
+ *
4
+ * Postmessage-style: subscribers register a topic string + handler; publishers
5
+ * emit a {topic, kind, ...} object. The exact same shape works inside Node,
6
+ * across worker_threads (postMessage), and across the WebView⇄host boundary
7
+ * (also postMessage). That's the load-bearing property: one mental model from
8
+ * DB write to UI re-render, on any platform.
9
+ *
10
+ * Topics
11
+ * ──────
12
+ * `message:<uuid>` — a specific message changed (flags, body, removal)
13
+ * `folder:<id>` — anything inside folder <id> changed; the bus fans
14
+ * out per-message events to the parent folder topic
15
+ * automatically (see fanOutToFolder())
16
+ * `account:<id>` — account-scoped event (sync status, auth, quota)
17
+ * `*` — wildcard; receives every event. Use for the IPC
18
+ * forwarder that pushes to the WebView, and for
19
+ * diagnostics. Avoid in normal UI subscribers.
20
+ *
21
+ * Batching
22
+ * ────────
23
+ * Bulk writes (sync round inserts 200 envelopes) would emit 200 events. The
24
+ * `withBatch(fn)` wrapper buffers events fired during fn() and coalesces
25
+ * per-topic into one {kind:"batch", changedUuids, summary} event at the end.
26
+ * Subscribers either iterate `changedUuids` or treat batch as "rerender this
27
+ * topic." Nested withBatch() is supported; inner scopes just append to the
28
+ * outer's buffer.
29
+ */
30
+ export type StoreEventKind = "messageInserted" | "messageUpdated" | "messageRemoved" | "messageMoved" | "flagsChanged" | "bodyAvailable" | "bodyFetchError" | "folderCountsChanged" | "draftSaved" | "draftSaveDeferred" | "batch";
31
+ export interface StoreEvent {
32
+ topic: string;
33
+ kind: StoreEventKind;
34
+ accountId?: string;
35
+ folderId?: number;
36
+ targetFolderId?: number;
37
+ uid?: number;
38
+ msgUuid?: string;
39
+ flags?: string[];
40
+ error?: string;
41
+ /** Set on `batch` events. UUIDs of every message touched in the scope. */
42
+ changedUuids?: string[];
43
+ /** Set on `batch` events. */
44
+ summary?: {
45
+ inserted: number;
46
+ updated: number;
47
+ deleted: number;
48
+ };
49
+ /** Arbitrary additional payload for kind-specific data the typed fields
50
+ * above don't cover (e.g. flag-set details, body path). Keep small. */
51
+ [extra: string]: unknown;
52
+ }
53
+ export type StoreEventHandler = (event: StoreEvent) => void;
54
+ export declare class StoreBus {
55
+ private subscribers;
56
+ private batchDepth;
57
+ private buffer;
58
+ subscribe(topic: string, handler: StoreEventHandler): () => void;
59
+ /** Publish an event. If a folderId is set and the topic is a message
60
+ * topic, the bus also publishes a copy to the parent folder topic so
61
+ * list views (subscribed to folder:<id>) wake without subscribing to
62
+ * every message individually. */
63
+ publish(event: StoreEvent): void;
64
+ /** Run `fn` with publishes buffered. On exit, coalesces per-topic and
65
+ * delivers either the single event (when only one fired for that topic)
66
+ * or a synthetic batch event summarizing the changes. */
67
+ withBatch<T>(fn: () => T): T;
68
+ private flush;
69
+ private deliver;
70
+ /** When a message-topic event fires and carries a folderId, generate
71
+ * the parallel folder-topic event so folder subscribers don't have to
72
+ * also subscribe to every message individually. Returns null when no
73
+ * fan-out applies. */
74
+ private fanOutToFolder;
75
+ }
76
+ /** Singleton bus shared by every Store consumer in the process. Workers
77
+ * get their own; the worker boundary serializes events via postMessage. */
78
+ export declare const storeBus: StoreBus;
79
+ //# sourceMappingURL=bus.d.ts.map
package/bus.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * StoreBus — typed pub/sub for Store events.
3
+ *
4
+ * Postmessage-style: subscribers register a topic string + handler; publishers
5
+ * emit a {topic, kind, ...} object. The exact same shape works inside Node,
6
+ * across worker_threads (postMessage), and across the WebView⇄host boundary
7
+ * (also postMessage). That's the load-bearing property: one mental model from
8
+ * DB write to UI re-render, on any platform.
9
+ *
10
+ * Topics
11
+ * ──────
12
+ * `message:<uuid>` — a specific message changed (flags, body, removal)
13
+ * `folder:<id>` — anything inside folder <id> changed; the bus fans
14
+ * out per-message events to the parent folder topic
15
+ * automatically (see fanOutToFolder())
16
+ * `account:<id>` — account-scoped event (sync status, auth, quota)
17
+ * `*` — wildcard; receives every event. Use for the IPC
18
+ * forwarder that pushes to the WebView, and for
19
+ * diagnostics. Avoid in normal UI subscribers.
20
+ *
21
+ * Batching
22
+ * ────────
23
+ * Bulk writes (sync round inserts 200 envelopes) would emit 200 events. The
24
+ * `withBatch(fn)` wrapper buffers events fired during fn() and coalesces
25
+ * per-topic into one {kind:"batch", changedUuids, summary} event at the end.
26
+ * Subscribers either iterate `changedUuids` or treat batch as "rerender this
27
+ * topic." Nested withBatch() is supported; inner scopes just append to the
28
+ * outer's buffer.
29
+ */
30
+ export class StoreBus {
31
+ subscribers = new Map();
32
+ batchDepth = 0;
33
+ buffer = [];
34
+ subscribe(topic, handler) {
35
+ let set = this.subscribers.get(topic);
36
+ if (!set) {
37
+ set = new Set();
38
+ this.subscribers.set(topic, set);
39
+ }
40
+ set.add(handler);
41
+ return () => {
42
+ const s = this.subscribers.get(topic);
43
+ if (s) {
44
+ s.delete(handler);
45
+ if (s.size === 0)
46
+ this.subscribers.delete(topic);
47
+ }
48
+ };
49
+ }
50
+ /** Publish an event. If a folderId is set and the topic is a message
51
+ * topic, the bus also publishes a copy to the parent folder topic so
52
+ * list views (subscribed to folder:<id>) wake without subscribing to
53
+ * every message individually. */
54
+ publish(event) {
55
+ if (this.batchDepth > 0) {
56
+ this.buffer.push(event);
57
+ // Folder fan-out also buffers; coalesces at flush time.
58
+ const fanned = this.fanOutToFolder(event);
59
+ if (fanned)
60
+ this.buffer.push(fanned);
61
+ return;
62
+ }
63
+ this.deliver(event);
64
+ const fanned = this.fanOutToFolder(event);
65
+ if (fanned)
66
+ this.deliver(fanned);
67
+ }
68
+ /** Run `fn` with publishes buffered. On exit, coalesces per-topic and
69
+ * delivers either the single event (when only one fired for that topic)
70
+ * or a synthetic batch event summarizing the changes. */
71
+ withBatch(fn) {
72
+ this.batchDepth++;
73
+ try {
74
+ return fn();
75
+ }
76
+ finally {
77
+ this.batchDepth--;
78
+ if (this.batchDepth === 0)
79
+ this.flush();
80
+ }
81
+ }
82
+ flush() {
83
+ const buf = this.buffer;
84
+ this.buffer = [];
85
+ if (buf.length === 0)
86
+ return;
87
+ const byTopic = new Map();
88
+ for (const e of buf) {
89
+ let arr = byTopic.get(e.topic);
90
+ if (!arr) {
91
+ arr = [];
92
+ byTopic.set(e.topic, arr);
93
+ }
94
+ arr.push(e);
95
+ }
96
+ for (const [topic, events] of byTopic) {
97
+ if (events.length === 1) {
98
+ this.deliver(events[0]);
99
+ continue;
100
+ }
101
+ const summary = { inserted: 0, updated: 0, deleted: 0 };
102
+ const uuids = new Set();
103
+ for (const e of events) {
104
+ if (e.kind === "messageInserted")
105
+ summary.inserted++;
106
+ else if (e.kind === "messageRemoved")
107
+ summary.deleted++;
108
+ else
109
+ summary.updated++;
110
+ if (e.msgUuid)
111
+ uuids.add(e.msgUuid);
112
+ }
113
+ this.deliver({ topic, kind: "batch", changedUuids: [...uuids], summary });
114
+ }
115
+ }
116
+ deliver(event) {
117
+ const exact = this.subscribers.get(event.topic);
118
+ if (exact)
119
+ for (const h of exact) {
120
+ try {
121
+ h(event);
122
+ }
123
+ catch (e) {
124
+ console.error("[store-bus]", e);
125
+ }
126
+ }
127
+ const wild = this.subscribers.get("*");
128
+ if (wild)
129
+ for (const h of wild) {
130
+ try {
131
+ h(event);
132
+ }
133
+ catch (e) {
134
+ console.error("[store-bus]", e);
135
+ }
136
+ }
137
+ }
138
+ /** When a message-topic event fires and carries a folderId, generate
139
+ * the parallel folder-topic event so folder subscribers don't have to
140
+ * also subscribe to every message individually. Returns null when no
141
+ * fan-out applies. */
142
+ fanOutToFolder(event) {
143
+ if (event.kind === "batch")
144
+ return null;
145
+ if (!event.topic.startsWith("message:"))
146
+ return null;
147
+ if (event.folderId == null)
148
+ return null;
149
+ return { ...event, topic: `folder:${event.folderId}` };
150
+ }
151
+ }
152
+ /** Singleton bus shared by every Store consumer in the process. Workers
153
+ * get their own; the worker boundary serializes events via postMessage. */
154
+ export const storeBus = new StoreBus();
155
+ //# sourceMappingURL=bus.js.map
package/index.d.ts CHANGED
@@ -4,5 +4,7 @@
4
4
  */
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
- export { parseSerial } from "./parse-serial.js";
7
+ export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
9
+ export type { StoreEvent, StoreEventKind, StoreEventHandler } from "@bobfrankston/mailx-bus";
8
10
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -4,5 +4,10 @@
4
4
  */
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
- export { parseSerial } from "./parse-serial.js";
7
+ export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ // Store-event bus lives in `@bobfrankston/mailx-bus` so the browser-side
9
+ // store (mailx-store-web) and the desktop-side store (this package) share
10
+ // the same bus. Re-exported here so existing callers don't have to learn
11
+ // the new import path.
12
+ export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
8
13
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,9 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.11",
13
- "@bobfrankston/mailx-settings": "^0.1.16",
12
+ "@bobfrankston/mailx-types": "^0.1.13",
13
+ "@bobfrankston/mailx-settings": "^0.1.17",
14
+ "@bobfrankston/mailx-bus": "^0.1.0",
14
15
  "mailparser": "^3.7.2"
15
16
  },
16
17
  "repository": {
@@ -23,12 +24,14 @@
23
24
  ".dependencies": {
24
25
  "@bobfrankston/mailx-types": "file:../mailx-types",
25
26
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
27
+ "@bobfrankston/mailx-bus": "file:../mailx-bus",
26
28
  "mailparser": "^3.7.2"
27
29
  },
28
30
  ".transformedSnapshot": {
29
31
  "dependencies": {
30
- "@bobfrankston/mailx-types": "^0.1.11",
31
- "@bobfrankston/mailx-settings": "^0.1.16",
32
+ "@bobfrankston/mailx-types": "^0.1.13",
33
+ "@bobfrankston/mailx-settings": "^0.1.17",
34
+ "@bobfrankston/mailx-bus": "^0.1.0",
32
35
  "mailparser": "^3.7.2"
33
36
  }
34
37
  }
package/parse-serial.d.ts CHANGED
@@ -1,34 +1,50 @@
1
1
  /**
2
- * Process-wide serialized simpleParser.
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 N parses run concurrently on the
6
- * single-threaded event loop they share CPU and each finishes at roughly
7
- * wall-clock. Real evidence (2026-05-13): four near-concurrent parses
8
- * each reported `14691ms for 3 KB` — pure contention, not size.
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
- * The downstream symptom is the IPC pipe: while the event loop is
11
- * saturated by interleaving parses, unrelated IPC operations (mark-as-spam,
12
- * move, delete) wait their turn and the WebView-side `mailxapi` shim
13
- * times out at 120s with a misleading "stayed in the list" alert.
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
- * Serializing through a module-level promise chain bounds the damage with
16
- * minimal plumbing: a single parse runs at full CPU and finishes in its
17
- * natural ~50-500ms budget; the next parse starts when it's done. Each
18
- * UI click produces a clean preview latency instead of a 14-second stall.
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
- * Lives in mailx-store rather than mailx-service so both the UI-hot path
21
- * (mailx-service/local-store.ts) and the sync path (mailx-imap) can share
22
- * one queue — otherwise sync-time parses would still contend with UI
23
- * parses through the event loop, just not through this module's chain.
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
- * Future work: replace the in-process chain with a `node:worker_threads`
26
- * pool. The chain is the precursor that bounds the worst case while the
27
- * pool is built.
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 { type ParsedMail, type Source } from "mailparser";
30
- /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
31
- * to "foreground" only sync/background callers should pass "background"
32
- * so user clicks aren't stuck behind a back-fill. */
37
+ import type { ParsedMail, Source } from "mailparser";
38
+ /** Spawn the parse worker early so its cold-start (mailparser module
39
+ * loading, V8 JIT, libmime / iconv-lite tables empirically 14-25 s)
40
+ * runs in parallel with the rest of boot. Safe to call multiple times;
41
+ * the worker is a process-singleton. Returns immediately; the actual
42
+ * cold-start completes in the worker's internal self-warm. */
43
+ export declare function prewarmParseWorker(): void;
44
+ /** Serialized wrapper around `mailparser.simpleParser` running in a worker
45
+ * thread. `priority` defaults to "foreground" — only sync/background
46
+ * callers should pass "background" so user clicks aren't stuck behind a
47
+ * back-fill. If the worker can't be spawned (rare), falls back to an
48
+ * in-main-thread serial pump so the system still functions. */
33
49
  export declare function parseSerial(source: Source, priority?: "foreground" | "background"): Promise<ParsedMail>;
34
50
  //# sourceMappingURL=parse-serial.d.ts.map
package/parse-serial.js CHANGED
@@ -1,40 +1,111 @@
1
1
  /**
2
- * Process-wide serialized simpleParser.
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 N parses run concurrently on the
6
- * single-threaded event loop they share CPU and each finishes at roughly
7
- * wall-clock. Real evidence (2026-05-13): four near-concurrent parses
8
- * each reported `14691ms for 3 KB` — pure contention, not size.
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
- * The downstream symptom is the IPC pipe: while the event loop is
11
- * saturated by interleaving parses, unrelated IPC operations (mark-as-spam,
12
- * move, delete) wait their turn and the WebView-side `mailxapi` shim
13
- * times out at 120s with a misleading "stayed in the list" alert.
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
- * Serializing through a module-level promise chain bounds the damage with
16
- * minimal plumbing: a single parse runs at full CPU and finishes in its
17
- * natural ~50-500ms budget; the next parse starts when it's done. Each
18
- * UI click produces a clean preview latency instead of a 14-second stall.
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
- * Lives in mailx-store rather than mailx-service so both the UI-hot path
21
- * (mailx-service/local-store.ts) and the sync path (mailx-imap) can share
22
- * one queue — otherwise sync-time parses would still contend with UI
23
- * parses through the event loop, just not through this module's chain.
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
- * Future work: replace the in-process chain with a `node:worker_threads`
26
- * pool. The chain is the precursor that bounds the worst case while the
27
- * pool is built.
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 { simpleParser } from "mailparser";
30
- const _head = []; // UI / foreground — drained first
31
- const _tail = []; // sync / background
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
- async function pump() {
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
- /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
56
- * to "foreground" only sync/background callers should pass "background"
57
- * so user clicks aren't stuck behind a back-fill. */
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
- pump();
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=parse-worker.d.ts.map
@@ -0,0 +1,69 @@
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
+ // CRITICAL: `Buffer` sent across `worker_threads.postMessage` arrives
46
+ // as plain `Uint8Array` (Node docs: "Buffer instances passed to
47
+ // Worker.postMessage() are passed as Uint8Array instances when
48
+ // received"). mailparser's `simpleParser` uses `Buffer.isBuffer(input)`
49
+ // to discriminate Buffer / string / Stream. A Uint8Array fails that
50
+ // check and gets routed to the Stream branch, which calls
51
+ // `input.once("end", …)` and immediately throws
52
+ // `input.once is not a function`
53
+ // Re-wrap with `Buffer.from(src.buffer, src.byteOffset, src.byteLength)`
54
+ // — zero-copy view over the same memory, but with the Buffer prototype
55
+ // so `Buffer.isBuffer` is true.
56
+ let normalized = source;
57
+ if (source instanceof Uint8Array && !Buffer.isBuffer(source)) {
58
+ const u = source;
59
+ normalized = Buffer.from(u.buffer, u.byteOffset, u.byteLength);
60
+ }
61
+ const result = await simpleParser(normalized);
62
+ parentPort.postMessage({ id, ok: true, result });
63
+ }
64
+ catch (e) {
65
+ const error = e instanceof Error ? e.message : String(e);
66
+ parentPort.postMessage({ id, ok: false, error });
67
+ }
68
+ });
69
+ //# sourceMappingURL=parse-worker.js.map